// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ChannelTypes, GeneralTypes, PostTypes} from '@mm-redux/action_types';
import {Posts} from '../../constants';
import {comparePosts} from '@mm-redux/utils/post_utils';
import {Post, PostsState, PostOrderBlock, MessageHistory} from '@mm-redux/types/posts';
import {RelationOneToOne, Dictionary, IDMappedObjects, RelationOneToMany} from '@mm-redux/types/utilities';
import {GenericAction} from '@mm-redux/types/actions';
import {Reaction} from '@mm-redux/types/reactions';

export function removeUnneededMetadata(post: Post) {
    if (!post.metadata) {
        return post;
    }

    const metadata = {...post.metadata};
    let changed = false;

    // These fields are stored separately
    if (metadata.emojis) {
        Reflect.deleteProperty(metadata, 'emojis');
        changed = true;
    }

    if (metadata.files) {
        Reflect.deleteProperty(metadata, 'files');
        changed = true;
    }

    if (metadata.reactions) {
        Reflect.deleteProperty(metadata, 'reactions');
        changed = true;
    }

    if (metadata.embeds) {
        let embedsChanged = false;

        const newEmbeds = metadata.embeds.map((embed) => {
            if (embed.type !== 'opengraph') {
                return embed;
            }

            const newEmbed = {...embed};
            Reflect.deleteProperty(newEmbed, 'data');

            embedsChanged = true;

            return newEmbed;
        });

        if (embedsChanged) {
            metadata.embeds = newEmbeds;
            changed = true;
        }
    }

    if (!changed) {
        // Nothing changed
        return post;
    }

    return {
        ...post,
        metadata,
    };
}

export function handlePosts(state: RelationOneToOne<Post, Post> = {}, action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_POST:
    case PostTypes.RECEIVED_NEW_POST: {
        return handlePostReceived({...state}, action.data);
    }

    case PostTypes.RECEIVED_POSTS: {
        const posts = Object.values(action.data.posts) as Post[];

        if (posts.length === 0) {
            return state;
        }

        const nextState = {...state};

        for (const post of posts) {
            handlePostReceived(nextState, post);
        }

        return nextState;
    }

    case PostTypes.POST_DELETED: {
        const post: Post = action.data;

        if (!state[post.id]) {
            return state;
        }

        // Mark the post as deleted
        const nextState = {
            ...state,
            [post.id]: {
                ...state[post.id],
                state: Posts.POST_DELETED,
                file_ids: [],
                has_reactions: false,
            },
        };

        // Remove any of its comments
        for (const otherPost of Object.values(state)) {
            if (otherPost.root_id === post.id) {
                Reflect.deleteProperty(nextState, otherPost.id);
            }
        }

        return nextState;
    }

    case PostTypes.POST_REMOVED: {
        const post = action.data;

        if (!state[post.id]) {
            return state;
        }

        // Remove the post itself
        const nextState = {...state};
        Reflect.deleteProperty(nextState, post.id);

        // Remove any of its comments
        for (const otherPost of Object.values(state)) {
            if (otherPost.root_id === post.id) {
                Reflect.deleteProperty(nextState, otherPost.id);
            }
        }

        return nextState;
    }

    case ChannelTypes.RECEIVED_CHANNEL_DELETED:
    case ChannelTypes.DELETE_CHANNEL_SUCCESS:
    case ChannelTypes.LEAVE_CHANNEL: {
        if (action.data && action.data.viewArchivedChannels) {
            // Nothing to do since we still want to store posts in archived channels
            return state;
        }

        const channelId = action.data.id;

        let postDeleted = false;

        // Remove any posts in the deleted channel
        const nextState = {...state};
        for (const post of Object.values(state)) {
            if (post.channel_id === channelId) {
                Reflect.deleteProperty(nextState, post.id);
                postDeleted = true;
            }
        }

        if (!postDeleted) {
            // Nothing changed
            return state;
        }

        return nextState;
    }
    default:
        return state;
    }
}

function handlePostReceived(nextState: any, post: Post) {
    if (nextState[post.id] && nextState[post.id].update_at >= post.update_at) {
        // The stored post is newer than the one we've received
        return nextState;
    }

    if (post.delete_at > 0) {
        // We've received a deleted post, so mark the post as deleted if we already have it
        if (nextState[post.id]) {
            nextState[post.id] = {
                ...removeUnneededMetadata(post),
                state: Posts.POST_DELETED,
                file_ids: [],
                has_reactions: false,
            };
        }
    } else {
        nextState[post.id] = removeUnneededMetadata(post);
    }

    // Delete any pending post that existed for this post
    if (post.pending_post_id && post.id !== post.pending_post_id && nextState[post.pending_post_id]) {
        Reflect.deleteProperty(nextState, post.pending_post_id);
    }

    return nextState;
}

export function handlePendingPosts(state: Array<string> = [], action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_NEW_POST: {
        const post = action.data;

        if (!post.pending_post_id) {
            // This is not a pending post
            return state;
        }

        const index = state.indexOf(post.pending_post_id);

        if (index !== -1) {
            // An entry already exists for this post
            return state;
        }

        // Add the new pending post ID
        const nextState = [...state];
        nextState.push(post.pending_post_id);

        return nextState;
    }
    case PostTypes.POST_REMOVED: {
        const post = action.data;

        const index = state.indexOf(post.id);

        if (index === -1) {
            // There's nothing to remove
            return state;
        }

        // The post has been removed, so remove the entry for it
        const nextState = [...state];
        nextState.splice(index, 1);

        return nextState;
    }
    case PostTypes.RECEIVED_POST: {
        const post = action.data;

        if (!post.pending_post_id) {
            // This isn't a pending post
            return state;
        }

        const index = state.indexOf(post.pending_post_id);

        if (index === -1) {
            // There's nothing to remove
            return state;
        }

        // The post has actually been created, so remove the entry for it
        const nextState = [...state];
        nextState.splice(index, 1);

        return nextState;
    }

    default:
        return state;
    }
}

export function postsInChannel(state: Dictionary<Array<PostOrderBlock>> = {}, action: GenericAction, prevPosts: IDMappedObjects<Post>, nextPosts: Dictionary<Post>) {
    switch (action.type) {
    case PostTypes.RECEIVED_NEW_POST: {
        const post = action.data as Post;

        const postsForChannel = state[post.channel_id];
        if (!postsForChannel) {
            // Don't save newly created posts until the channel has been loaded
            return state;
        }

        const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);

        let nextRecentBlock: PostOrderBlock;
        if (recentBlockIndex === -1) {
            nextRecentBlock = {
                order: [],
                recent: true,
            };
        } else {
            const recentBlock = postsForChannel[recentBlockIndex];
            nextRecentBlock = {
                ...recentBlock,
                order: [...recentBlock.order],
            };
        }

        let changed = false;

        // Add the new post to the channel
        if (!nextRecentBlock.order.includes(post.id)) {
            nextRecentBlock.order.unshift(post.id);
            changed = true;
        }

        // If this is a newly created post, remove any pending post that exists for it
        if (post.pending_post_id && post.id !== post.pending_post_id) {
            const index = nextRecentBlock.order.indexOf(post.pending_post_id);

            if (index !== -1) {
                nextRecentBlock.order.splice(index, 1);

                // Need to re-sort to make sure any other pending posts come first
                nextRecentBlock.order.sort((a, b) => {
                    return comparePosts(nextPosts[a], nextPosts[b]);
                });
                changed = true;
            }
        }

        if (!changed) {
            return state;
        }

        const nextPostsForChannel = [...postsForChannel];

        if (recentBlockIndex === -1) {
            nextPostsForChannel.push(nextRecentBlock);
        } else {
            nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
        }

        return {
            ...state,
            [post.channel_id]: nextPostsForChannel,
        };
    }

    case PostTypes.RECEIVED_POST: {
        const post = action.data;

        // Receiving a single post doesn't usually affect the order of posts in a channel, except for when we've
        // received a newly created post that was previously stored as pending

        if (!post.pending_post_id) {
            return state;
        }

        const postsForChannel = state[post.channel_id] || [];

        const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
        if (recentBlockIndex === -1) {
            // Nothing to do since there's no recent block and only the recent block should contain pending posts
            return state;
        }

        const recentBlock = postsForChannel[recentBlockIndex];

        // Replace the pending post with the newly created one
        const index = recentBlock.order.indexOf(post.pending_post_id);
        if (index === -1) {
            // No pending post found to remove
            return state;
        }

        const nextRecentBlock = {
            ...recentBlock,
            order: [...recentBlock.order],
        };

        nextRecentBlock.order[index] = post.id;

        const nextPostsForChannel = [...postsForChannel];
        nextPostsForChannel[recentBlockIndex] = nextRecentBlock;

        return {
            ...state,
            [post.channel_id]: nextPostsForChannel,
        };
    }

    case PostTypes.RECEIVED_POSTS_IN_CHANNEL: {
        const {recent, oldest} = action;
        const order = action.data.order;

        if (order.length === 0 && state[action.channelId]) {
            // No new posts received when we already have posts
            return state;
        }

        const postsForChannel = state[action.channelId] || [];
        let nextPostsForChannel = [...postsForChannel];

        if (recent) {
            // The newly received block is now the most recent, so unmark the current most recent block
            const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
            if (recentBlockIndex !== -1) {
                const recentBlock = postsForChannel[recentBlockIndex];

                if (recentBlock.order.length === order.length &&
                    recentBlock.order[0] === order[0] &&
                    recentBlock.order[recentBlock.order.length - 1] === order[order.length - 1]) {
                    // The newly received posts are identical to the most recent block, so there's nothing to do
                    return state;
                }

                // Unmark the most recent block since the new posts are more recent
                const nextRecentBlock = {
                    ...recentBlock,
                    recent: false,
                };

                nextPostsForChannel[recentBlockIndex] = nextRecentBlock;
            }
        }

        // Add the new most recent block
        nextPostsForChannel.push({
            order,
            recent,
            oldest,
        });

        // Merge overlapping blocks
        nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);

        return {
            ...state,
            [action.channelId]: nextPostsForChannel,
        };
    }

    case PostTypes.RECEIVED_POSTS_AFTER: {
        const order = action.data.order;
        const afterPostId = action.afterPostId;

        if (order.length === 0) {
            // No posts received
            return state;
        }

        const postsForChannel = state[action.channelId] || [];

        // Add a new block including the previous post and then have mergePostBlocks sort out any overlap or duplicates
        const newBlock = {
            order: [...order, afterPostId],
            recent: action.recent,
        };

        let nextPostsForChannel = [...postsForChannel, newBlock];
        nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);

        return {
            ...state,
            [action.channelId]: nextPostsForChannel,
        };
    }

    case PostTypes.RECEIVED_POSTS_BEFORE: {
        const {order} = action.data;
        const {beforePostId, oldest} = action;

        if (order.length === 0) {
            // No posts received
            return state;
        }

        const postsForChannel = state[action.channelId] || [];

        // Add a new block including the next post and then have mergePostBlocks sort out any overlap or duplicates
        const newBlock = {
            order: [beforePostId, ...order],
            recent: false,
            oldest,
        };

        let nextPostsForChannel = [...postsForChannel, newBlock];
        nextPostsForChannel = mergePostBlocks(nextPostsForChannel, nextPosts);

        return {
            ...state,
            [action.channelId]: nextPostsForChannel,
        };
    }

    case PostTypes.RECEIVED_POSTS_SINCE: {
        const order = action.data.order;

        if (order.length === 0 && state[action.channelId]) {
            // No new posts received when we already have posts
            return state;
        }

        const postsForChannel = state[action.channelId] || [];

        const recentBlockIndex = postsForChannel.findIndex((block: PostOrderBlock) => block.recent);
        if (recentBlockIndex === -1) {
            // Nothing to do since this shouldn't be dispatched if we haven't loaded the most recent posts yet
            return state;
        }

        const recentBlock = postsForChannel[recentBlockIndex];

        const mostOldestCreateAt = nextPosts[recentBlock.order[recentBlock.order.length - 1]].create_at;

        const nextRecentBlock: PostOrderBlock = {
            ...recentBlock,
            order: [...recentBlock.order],
        };

        // Add any new posts to the most recent block while skipping ones that were only updated
        for (let i = order.length - 1; i >= 0; i--) {
            const postId = order[i];

            if (!nextPosts[postId]) {
                // the post was removed from the list
                continue;
            }

            if (nextPosts[postId].create_at <= mostOldestCreateAt) {
                // This is an old post
                continue;
            }

            if (nextRecentBlock.order.indexOf(postId) !== -1) {
                // This postId exists so no need to add it again
                continue;
            }

            // This post is newer than what we have
            nextRecentBlock.order.unshift(postId);
        }

        if (nextRecentBlock.order.length === recentBlock.order.length) {
            // Nothing was added
            return state;
        }

        nextRecentBlock.order.sort((a, b) => {
            return comparePosts(nextPosts[a], nextPosts[b]);
        });

        const nextPostsForChannel = [...postsForChannel];
        nextPostsForChannel[recentBlockIndex] = nextRecentBlock;

        return {
            ...state,
            [action.channelId]: nextPostsForChannel,
        };
    }

    case PostTypes.POST_DELETED: {
        const post = action.data;

        // Deleting a post removes its comments from the order, but does not remove the post itself

        const postsForChannel = state[post.channel_id] || [];
        if (postsForChannel.length === 0) {
            return state;
        }

        let changed = false;

        let nextPostsForChannel = [...postsForChannel];
        for (let i = 0; i < nextPostsForChannel.length; i++) {
            const block = nextPostsForChannel[i];

            // Remove any comments for this post
            const nextOrder = block.order.filter((postId: string) => prevPosts[postId].root_id !== post.id);

            if (nextOrder.length !== block.order.length) {
                nextPostsForChannel[i] = {
                    ...block,
                    order: nextOrder,
                };

                changed = true;
            }
        }

        if (!changed) {
            // Nothing was removed
            return state;
        }

        nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);

        return {
            ...state,
            [post.channel_id]: nextPostsForChannel,
        };
    }

    case PostTypes.POST_REMOVED: {
        const post = action.data;

        // Removing a post removes it as well as its comments

        const postsForChannel = state[post.channel_id] || [];
        if (postsForChannel.length === 0) {
            return state;
        }

        let changed = false;

        // Remove the post and its comments from the channel
        let nextPostsForChannel = [...postsForChannel];
        for (let i = 0; i < nextPostsForChannel.length; i++) {
            const block = nextPostsForChannel[i];

            const nextOrder = block.order.filter((postId: string) => postId !== post.id && prevPosts[postId].root_id !== post.id);

            if (nextOrder.length !== block.order.length) {
                nextPostsForChannel[i] = {
                    ...block,
                    order: nextOrder,
                };

                changed = true;
            }
        }

        if (!changed) {
            // Nothing was removed
            return state;
        }

        nextPostsForChannel = removeNonRecentEmptyPostBlocks(nextPostsForChannel);

        return {
            ...state,
            [post.channel_id]: nextPostsForChannel,
        };
    }

    case ChannelTypes.RECEIVED_CHANNEL_DELETED:
    case ChannelTypes.DELETE_CHANNEL_SUCCESS:
    case ChannelTypes.LEAVE_CHANNEL: {
        if (action.data && action.data.viewArchivedChannels) {
            // Nothing to do since we still want to store posts in archived channels
            return state;
        }

        const channelId = action.data.id;

        if (!state[channelId]) {
            // Nothing to do since we have no posts for this channel
            return state;
        }

        // Remove the entry for the deleted channel
        const nextState = {...state};
        Reflect.deleteProperty(nextState, channelId);

        return nextState;
    }
    default:
        return state;
    }
}

export function removeNonRecentEmptyPostBlocks(blocks: PostOrderBlock[]) {
    return blocks.filter((block: PostOrderBlock) => block.order.length !== 0 || block.recent);
}

export function mergePostBlocks(blocks: PostOrderBlock[], posts: Dictionary<Post>) {
    let nextBlocks = [...blocks];

    // Remove any blocks that may have become empty by removing posts
    nextBlocks = removeNonRecentEmptyPostBlocks(blocks);

    // If a channel does not have any posts(Experimental feature where join and leave messages don't exist)
    // return the previous state i.e an empty block
    if (!nextBlocks.length) {
        return blocks;
    }

    // Sort blocks so that the most recent one comes first
    nextBlocks.sort((a, b) => {
        const aStartsAt = posts[a.order[0]].create_at;
        const bStartsAt = posts[b.order[0]].create_at;

        return bStartsAt - aStartsAt;
    });

    // Merge adjacent blocks
    let i = 0;
    while (i < nextBlocks.length - 1) {
        // Since we know the start of a is more recent than the start of b, they'll overlap if the last post in a is
        // older than the first post in b
        const a = nextBlocks[i];
        const aEndsAt = posts[a.order[a.order.length - 1]].create_at;

        const b = nextBlocks[i + 1];
        const bStartsAt = posts[b.order[0]].create_at;

        if (aEndsAt <= bStartsAt) {
            // The blocks overlap, so combine them and remove the second block
            nextBlocks[i] = {
                order: mergePostOrder(a.order, b.order, posts),
            };

            nextBlocks[i].recent = a.recent || b.recent;
            nextBlocks[i].oldest = a.oldest || b.oldest;

            nextBlocks.splice(i + 1, 1);

            // Do another iteration on this index since it may need to be merged into the next
        } else {
            // The blocks don't overlap, so move on to the next one
            i += 1;
        }
    }

    if (blocks.length === nextBlocks.length) {
        // No changes were made
        return blocks;
    }

    return nextBlocks;
}

export function mergePostOrder(left: string[], right: string[], posts: Dictionary<Post>) {
    const result = [...left];

    // Add without duplicates
    const seen = new Set(left);
    for (const id of right) {
        if (seen.has(id)) {
            continue;
        }

        result.push(id);
    }

    if (result.length === left.length) {
        // No new items added
        return left;
    }

    // Re-sort so that the most recent post comes first
    result.sort((a, b) => posts[b].create_at - posts[a].create_at);

    return result;
}

export function postsInThread(state: RelationOneToMany<Post, Post> = {}, action: GenericAction, prevPosts: Dictionary<Post>) {
    switch (action.type) {
    case PostTypes.RECEIVED_NEW_POST:
    case PostTypes.RECEIVED_POST: {
        const post = action.data;

        if (!post.root_id) {
            // Only store comments, not the root post
            return state;
        }

        const postsForThread = state[post.root_id] || [];
        const nextPostsForThread = [...postsForThread];

        let changed = false;

        if (!postsForThread.includes(post.id)) {
            nextPostsForThread.push(post.id);
            changed = true;
        }

        // If this is a new non-pending post, remove any pending post that exists for it
        if (post.pending_post_id && post.id !== post.pending_post_id) {
            const index = nextPostsForThread.indexOf(post.pending_post_id);

            if (index !== -1) {
                nextPostsForThread.splice(index, 1);
                changed = true;
            }
        }

        if (!changed) {
            return state;
        }

        return {
            ...state,
            [post.root_id]: nextPostsForThread,
        };
    }

    case PostTypes.RECEIVED_POSTS_AFTER:
    case PostTypes.RECEIVED_POSTS_BEFORE:
    case PostTypes.RECEIVED_POSTS_IN_CHANNEL:
    case PostTypes.RECEIVED_POSTS_SINCE: {
        const newPosts: Post[] = Object.values(action.data.posts);

        if (newPosts.length === 0) {
            // Nothing to add
            return state;
        }

        const nextState: Dictionary<string[]> = {};

        for (const post of newPosts) {
            if (!post.root_id) {
                // Only store comments, not the root post
                continue;
            }

            const postsForThread = state[post.root_id] || [];
            const nextPostsForThread = nextState[post.root_id] || [...postsForThread];

            // Add the post to the thread
            if (!nextPostsForThread.includes(post.id)) {
                nextPostsForThread.push(post.id);
            }

            nextState[post.root_id] = nextPostsForThread;
        }

        if (Object.keys(nextState).length === 0) {
            return state;
        }

        return {
            ...state,
            ...nextState,
        };
    }

    case PostTypes.RECEIVED_POSTS_IN_THREAD: {
        const newPosts: Post[] = Object.values(action.data.posts);

        if (newPosts.length === 0) {
            // Nothing to add
            return state;
        }

        const postsForThread = state[action.rootId] || [];
        const nextPostsForThread = [...postsForThread];

        for (const post of newPosts) {
            if (post.root_id !== action.rootId) {
                // Only store comments
                continue;
            }

            if (nextPostsForThread.includes(post.id)) {
                // Don't store duplicates
                continue;
            }

            nextPostsForThread.push(post.id);
        }

        return {
            ...state,
            [action.rootId]: nextPostsForThread,
        };
    }

    case PostTypes.POST_DELETED: {
        const post = action.data;

        const postsForThread = state[post.id];
        if (!postsForThread) {
            // Nothing to remove
            return state;
        }

        const nextState = {...state};
        Reflect.deleteProperty(nextState, post.id);

        return nextState;
    }

    case PostTypes.POST_REMOVED: {
        const post = action.data;

        if (post.root_id) {
            // This is a comment, so remove it from the thread
            const postsForThread = state[post.root_id];
            if (!postsForThread) {
                return state;
            }

            const index = postsForThread.findIndex((postId) => postId === post.id);
            if (index === -1) {
                return state;
            }

            const nextPostsForThread = [...postsForThread];
            nextPostsForThread.splice(index, 1);

            return {
                ...state,
                [post.root_id]: nextPostsForThread,
            };
        }

        // This is not a comment, so remove any comments on it
        const postsForThread = state[post.id];
        if (!postsForThread) {
            return state;
        }

        const nextState = {...state};
        Reflect.deleteProperty(nextState, post.id);

        return nextState;
    }

    case ChannelTypes.RECEIVED_CHANNEL_DELETED:
    case ChannelTypes.DELETE_CHANNEL_SUCCESS:
    case ChannelTypes.LEAVE_CHANNEL: {
        if (action.data && action.data.viewArchivedChannels) {
            // Nothing to do since we still want to store posts in archived channels
            return state;
        }

        const channelId = action.data.id;

        let postDeleted = false;

        // Remove entries for any thread in the channel
        const nextState = {...state};
        for (const rootId of Object.keys(state)) {
            if (prevPosts[rootId] && prevPosts[rootId].channel_id === channelId) {
                Reflect.deleteProperty(nextState, rootId);
                postDeleted = true;
            }
        }

        if (!postDeleted) {
            // Nothing was actually removed
            return state;
        }

        return nextState;
    }
    default:
        return state;
    }
}

function selectedPostId(state = '', action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_POST_SELECTED:
        return action.data;
    default:
        return state;
    }
}

function currentFocusedPostId(state = '', action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_FOCUSED_POST:
        return action.data;
    default:
        return state;
    }
}

export function reactions(state: RelationOneToOne<Post, Dictionary<Reaction>> = {}, action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_REACTIONS: {
        const reactionsList = action.data;
        const nextReactions: Dictionary<Reaction> = {};
        reactionsList.forEach((reaction: Reaction) => {
            nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction;
        });

        return {
            ...state,
            [action.postId!]: nextReactions,
        };
    }
    case PostTypes.RECEIVED_REACTION: {
        const reaction = action.data as Reaction;
        const nextReactions = {...(state[reaction.post_id] || {})};
        nextReactions[reaction.user_id + '-' + reaction.emoji_name] = reaction;

        return {
            ...state,
            [reaction.post_id]: nextReactions,
        };
    }
    case PostTypes.REACTION_DELETED: {
        const reaction = action.data;
        const nextReactions = {...(state[reaction.post_id] || {})};
        if (!nextReactions[reaction.user_id + '-' + reaction.emoji_name]) {
            return state;
        }

        Reflect.deleteProperty(nextReactions, reaction.user_id + '-' + reaction.emoji_name);

        return {
            ...state,
            [reaction.post_id]: nextReactions,
        };
    }

    case PostTypes.RECEIVED_NEW_POST:
    case PostTypes.RECEIVED_POST: {
        const post = action.data;

        return storeReactionsForPost(state, post);
    }

    case PostTypes.RECEIVED_POSTS: {
        const posts = Object.values(action.data.posts);

        return posts.reduce(storeReactionsForPost, state);
    }

    case PostTypes.POST_DELETED:
    case PostTypes.POST_REMOVED: {
        const post = action.data;

        if (post && state[post.id]) {
            const nextState = {...state};
            Reflect.deleteProperty(nextState, post.id);

            return nextState;
        }

        return state;
    }

    default:
        return state;
    }
}

function storeReactionsForPost(state: any, post: Post) {
    if (!post.metadata || !post.metadata.reactions || post.delete_at > 0) {
        return state;
    }

    const reactionsForPost: Dictionary<Reaction> = {};
    if (post.metadata.reactions && post.metadata.reactions.length > 0) {
        for (const reaction of post.metadata.reactions) {
            reactionsForPost[reaction.user_id + '-' + reaction.emoji_name] = reaction;
        }
    }

    return {
        ...state,
        [post.id]: reactionsForPost,
    };
}

export function openGraph(state: RelationOneToOne<Post, any> = {}, action: GenericAction) {
    switch (action.type) {
    case PostTypes.RECEIVED_OPEN_GRAPH_METADATA: {
        const nextState = {...state};
        nextState[action.url] = action.data;

        return nextState;
    }

    case PostTypes.RECEIVED_NEW_POST:
    case PostTypes.RECEIVED_POST: {
        const post = action.data;

        return storeOpenGraphForPost(state, post);
    }
    case PostTypes.RECEIVED_POSTS: {
        const posts = Object.values(action.data.posts);

        return posts.reduce(storeOpenGraphForPost, state);
    }

    default:
        return state;
    }
}

function storeOpenGraphForPost(state: any, post: Post) {
    if (!post.metadata || !post.metadata.embeds) {
        return state;
    }

    return post.metadata.embeds.reduce((nextState, embed) => {
        if (embed.type !== 'opengraph' || !embed.data) {
            // Not an OpenGraph embed
            return nextState;
        }

        const postIdState = nextState[post.id] ? {...nextState[post.id], [embed.url]: embed.data} : {[embed.url]: embed.data};
        return {
            ...nextState,
            [post.id]: postIdState,
        };
    }, state);
}

function messagesHistory(state: Partial<MessageHistory> = {}, action: GenericAction) {
    switch (action.type) {
    case PostTypes.ADD_MESSAGE_INTO_HISTORY: {
        const nextIndex: Dictionary<number> = {};
        let nextMessages = state.messages ? [...state.messages] : [];
        nextMessages.push(action.data);
        nextIndex[Posts.MESSAGE_TYPES.POST] = nextMessages.length;
        nextIndex[Posts.MESSAGE_TYPES.COMMENT] = nextMessages.length;

        if (nextMessages.length > Posts.MAX_PREV_MSGS) {
            nextMessages = nextMessages.slice(1, Posts.MAX_PREV_MSGS + 1);
        }

        return {
            messages: nextMessages,
            index: nextIndex,
        };
    }
    case PostTypes.RESET_HISTORY_INDEX: {
        const index: Dictionary<number> = {};
        index[Posts.MESSAGE_TYPES.POST] = -1;
        index[Posts.MESSAGE_TYPES.COMMENT] = -1;

        const messages = state.messages || [];
        const nextIndex = state.index ? {...state.index} : index;
        nextIndex[action.data] = messages.length;
        return {
            messages: state.messages,
            index: nextIndex,
        };
    }
    case PostTypes.MOVE_HISTORY_INDEX_BACK: {
        const index: Dictionary<number> = {};
        index[Posts.MESSAGE_TYPES.POST] = -1;
        index[Posts.MESSAGE_TYPES.COMMENT] = -1;

        const nextIndex = state.index ? {...state.index} : index;
        if (nextIndex[action.data] > 0) {
            nextIndex[action.data]--;
        }
        return {
            messages: state.messages,
            index: nextIndex,
        };
    }
    case PostTypes.MOVE_HISTORY_INDEX_FORWARD: {
        const index: Dictionary<number> = {};
        index[Posts.MESSAGE_TYPES.POST] = -1;
        index[Posts.MESSAGE_TYPES.COMMENT] = -1;

        const messages = state.messages || [];
        const nextIndex = state.index ? {...state.index} : index;
        if (nextIndex[action.data] < messages.length) {
            nextIndex[action.data]++;
        }
        return {
            messages: state.messages,
            index: nextIndex,
        };
    }
    default:
        return state;
    }
}

export function expandedURLs(state: Dictionary<string> = {}, action: GenericAction) {
    switch (action.type) {
    case GeneralTypes.REDIRECT_LOCATION_SUCCESS:
        return {
            ...state,
            [action.data.url]: action.data.location,
        };
    case GeneralTypes.REDIRECT_LOCATION_FAILURE:
        return {
            ...state,
            [action.data.url]: action.data.url,
        };
    default:
        return state;
    }
}

export default function reducer(state: Partial<PostsState> = {}, action: GenericAction) {
    const nextPosts = handlePosts(state.posts, action);
    const nextPostsInChannel = postsInChannel(state.postsInChannel, action, state.posts!, nextPosts);

    const nextState = {

        // Object mapping post ids to post objects
        posts: nextPosts,

        // Array that contains the pending post ids for those messages that are in transition to being created
        pendingPostIds: handlePendingPosts(state.pendingPostIds, action),

        // Object mapping channel ids to an array of posts ids in that channel with the most recent post first
        postsInChannel: nextPostsInChannel,

        // Object mapping post root ids to an array of posts ids of comments (but not the root post) in that thread
        // with no guaranteed order
        postsInThread: postsInThread(state.postsInThread, action, state.posts!),

        // The current selected post
        selectedPostId: selectedPostId(state.selectedPostId, action),

        // The current selected focused post (permalink view)
        currentFocusedPostId: currentFocusedPostId(state.currentFocusedPostId, action),

        // Object mapping post ids to an object of emoji reactions using userId-emojiName as keys
        reactions: reactions(state.reactions, action),

        // Object mapping URLs to their relevant opengraph metadata for link previews
        openGraph: openGraph(state.openGraph, action),

        // History of posts and comments
        messagesHistory: messagesHistory(state.messagesHistory, action),

        expandedURLs: expandedURLs(state.expandedURLs, action),
    };

    if (state.posts === nextState.posts && state.postsInChannel === nextState.postsInChannel &&
        state.postsInThread === nextState.postsInThread &&
        state.pendingPostIds === nextState.pendingPostIds &&
        state.selectedPostId === nextState.selectedPostId &&
        state.currentFocusedPostId === nextState.currentFocusedPostId &&
        state.reactions === nextState.reactions &&
        state.openGraph === nextState.openGraph &&
        state.messagesHistory === nextState.messagesHistory &&
        state.expandedURLs === nextState.expandedURLs) {
        // None of the children have changed so don't even let the parent object change
        return state;
    }

    return nextState;
}
