/**
 * @license
 * Copyright (C) 2017 The Android Open Source Project
 *
 * 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.
 */
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment-api_html';
import {CURRENT} from '../../../utils/patch-set-util';
import {customElement, property} from '@polymer/decorators';
import {
  CommentBasics,
  PatchRange,
  PatchSetNum,
  PathToRobotCommentsInfoMap,
  RobotCommentInfo,
  UrlEncodedCommentId,
  NumericChangeId,
  PathToCommentsInfoMap,
  FileInfo,
  ParentPatchSetNum,
} from '../../../types/common';
import {
  Comment,
  CommentMap,
  CommentThread,
  DraftInfo,
  isUnresolved,
  UIComment,
  UIDraft,
  UIHuman,
  UIRobot,
  createCommentThreads,
  isInPatchRange,
  isDraftThread,
  isInBaseOfPatchRange,
  isInRevisionOfPatchRange,
  isPatchsetLevel,
} from '../../../utils/comment-util';
import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
import {appContext} from '../../../services/app-context';
import {CommentSide, Side} from '../../../constants/constants';
import {pluralize} from '../../../utils/string-util';

export type CommentIdToCommentThreadMap = {
  [urlEncodedCommentId: string]: CommentThread;
};

export class ChangeComments {
  private readonly _comments: {[path: string]: UIHuman[]};

  private readonly _robotComments: {[path: string]: UIRobot[]};

  private readonly _drafts: {[path: string]: UIDraft[]};

  private readonly _portedComments: PathToCommentsInfoMap;

  private readonly _portedDrafts: PathToCommentsInfoMap;

  /**
   * Construct a change comments object, which can be data-bound to child
   * elements of that which uses the gr-comment-api.
   */
  constructor(
    comments: {[path: string]: UIHuman[]} | undefined,
    robotComments: {[path: string]: UIRobot[]} | undefined,
    drafts: {[path: string]: UIDraft[]} | undefined,
    portedComments: PathToCommentsInfoMap | undefined,
    portedDrafts: PathToCommentsInfoMap | undefined
  ) {
    this._comments = this._addPath(comments);
    this._robotComments = this._addPath(robotComments);
    this._drafts = this._addPath(drafts);
    this._portedComments = portedComments || {};
    this._portedDrafts = portedDrafts || {};
  }

  /**
   * Add path info to every comment as CommentInfo returned
   * from server does not have that.
   *
   * TODO(taoalpha): should consider changing BE to send path
   * back within CommentInfo
   */
  _addPath<T>(
    comments: {[path: string]: T[]} = {}
  ): {[path: string]: Array<T & {path: string}>} {
    const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
    for (const filePath of Object.keys(comments)) {
      const allCommentsForPath = comments[filePath] || [];
      if (allCommentsForPath.length) {
        updatedComments[filePath] = allCommentsForPath.map(comment => {
          return {...comment, path: filePath};
        });
      }
    }
    return updatedComments;
  }

  get drafts() {
    return this._drafts;
  }

  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
    if (!commentId) return undefined;
    const findComment = (comments: {[path: string]: UIComment[]}) => {
      let comment;
      for (const path of Object.keys(comments)) {
        comment = comment || comments[path].find(c => c.id === commentId);
      }
      return comment;
    };
    return (
      findComment(this._comments) ||
      findComment(this._robotComments) ||
      findComment(this._drafts)
    );
  }

  /**
   * Get an object mapping file paths to a boolean representing whether that
   * path contains diff comments in the given patch set (including drafts and
   * robot comments).
   *
   * Paths with comments are mapped to true, whereas paths without comments
   * are not mapped.
   *
   * @param patchRange The patch-range object containing
   * patchNum and basePatchNum properties to represent the range.
   */
  getPaths(patchRange?: PatchRange): CommentMap {
    const responses: {[path: string]: UIComment[]}[] = [
      this._comments,
      this.drafts,
      this._robotComments,
    ];
    const commentMap: CommentMap = {};
    for (const response of responses) {
      for (const [path, comments] of Object.entries(response)) {
        // If don't care about patch range, we know that the path exists.
        if (comments.some(c => !patchRange || isInPatchRange(c, patchRange))) {
          commentMap[path] = true;
        }
      }
    }
    return commentMap;
  }

  /**
   * Gets all the comments and robot comments for the given change.
   */
  getAllPublishedComments(patchNum?: PatchSetNum) {
    return this.getAllComments(false, patchNum);
  }

  /**
   * Gets all the comments for a particular thread group. Used for refreshing
   * comments after the thread group has already been built.
   */
  getCommentsForThread(rootId: UrlEncodedCommentId) {
    const allThreads = this.getAllThreadsForChange();
    const threadMatch = allThreads.find(t => t.rootId === rootId);

    // In the event that a single draft comment was removed by the thread-list
    // and the diff view is updating comments, there will no longer be a thread
    // found.  In this case, return null.
    return threadMatch ? threadMatch.comments : null;
  }

  /**
   * Gets all the comments and robot comments for the given change.
   */
  getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
    const paths = this.getPaths();
    const publishedComments: {[path: string]: CommentBasics[]} = {};
    for (const path of Object.keys(paths)) {
      publishedComments[path] = this.getAllCommentsForPath(
        path,
        patchNum,
        includeDrafts
      );
    }
    return publishedComments;
  }

  /**
   * Gets all the drafts for the given change.
   */
  getAllDrafts(patchNum?: PatchSetNum) {
    const paths = this.getPaths();
    const drafts: {[path: string]: UIDraft[]} = {};
    for (const path of Object.keys(paths)) {
      drafts[path] = this.getAllDraftsForPath(path, patchNum);
    }
    return drafts;
  }

  /**
   * Get the comments (robot comments) for a path and optional patch num.
   *
   * This method will always return a new shallow copy of all comments,
   * so manipulation on one copy won't affect other copies.
   *
   */
  getAllCommentsForPath(
    path: string,
    patchNum?: PatchSetNum,
    includeDrafts?: boolean
  ): Comment[] {
    const comments: Comment[] = this._comments[path] || [];
    const robotComments = this._robotComments[path] || [];
    let allComments = comments.concat(robotComments);
    if (includeDrafts) {
      const drafts = this.getAllDraftsForPath(path);
      allComments = allComments.concat(drafts);
    }
    if (patchNum) {
      allComments = allComments.filter(c => c.patch_set === patchNum);
    }
    return allComments.map(c => {
      return {...c};
    });
  }

  /**
   * Get the comments (robot comments) for a file.
   *
   * // TODO(taoalpha): maybe merge in *ForPath
   */
  getAllCommentsForFile(file: PatchSetFile, includeDrafts?: boolean) {
    let allComments = this.getAllCommentsForPath(
      file.path,
      file.patchNum,
      includeDrafts
    );

    if (file.basePath) {
      allComments = allComments.concat(
        this.getAllCommentsForPath(file.basePath, file.patchNum, includeDrafts)
      );
    }

    return allComments;
  }

  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
    return new ChangeComments(
      this._comments,
      this._robotComments,
      drafts,
      this._portedComments,
      this._portedDrafts
    );
  }

  cloneWithUpdatedPortedComments(
    portedComments?: PathToCommentsInfoMap,
    portedDrafts?: PathToCommentsInfoMap
  ) {
    return new ChangeComments(
      this._comments,
      this._robotComments,
      this._drafts,
      portedComments,
      portedDrafts
    );
  }

  /**
   * Get the drafts for a path and optional patch num.
   *
   * This will return a shallow copy of all drafts every time,
   * so changes on any copy will not affect other copies.
   */
  getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
    let comments = this._drafts[path] || [];
    if (patchNum) {
      comments = comments.filter(c => c.patch_set === patchNum);
    }
    return comments.map(c => {
      return {...c, __draft: true};
    });
  }

  /**
   * Get the drafts for a file.
   *
   * // TODO(taoalpha): maybe merge in *ForPath
   */
  getAllDraftsForFile(file: PatchSetFile): Comment[] {
    let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
    if (file.basePath) {
      allDrafts = allDrafts.concat(
        this.getAllDraftsForPath(file.basePath, file.patchNum)
      );
    }
    return allDrafts;
  }

  /**
   * Get the comments (with drafts and robot comments) for a path and
   * patch-range. Returns an object with left and right properties mapping to
   * arrays of comments in on either side of the patch range for that path.
   *
   * @param patchRange The patch-range object containing patchNum
   * and basePatchNum properties to represent the range.
   * @param projectConfig Optional project config object to
   * include in the meta sub-object.
   */
  getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
    let comments: Comment[] = [];
    let drafts: DraftInfo[] = [];
    let robotComments: RobotCommentInfo[] = [];
    if (this._comments && this._comments[path]) {
      comments = this._comments[path];
    }
    if (this.drafts && this.drafts[path]) {
      drafts = this.drafts[path];
    }
    if (this._robotComments && this._robotComments[path]) {
      robotComments = this._robotComments[path];
    }

    drafts.forEach(d => {
      d.__draft = true;
    });

    return comments
      .concat(drafts)
      .concat(robotComments)
      .filter(c => isInPatchRange(c, patchRange))
      .map(c => {
        return {...c};
      });
  }

  /**
   * Get the ported threads for given patch range.
   * Ported threads are comment threads that were posted on an older patchset
   * and are displayed on a later patchset.
   * It is simply the original thread displayed on a newer patchset.
   *
   * Threads are ported over to all subsequent patchsets. So, a thread created
   * on patchset 5 say will be ported over to patchsets 6,7,8 and beyond.
   *
   * Ported threads add a boolean property ported true to the thread object
   * to indicate to the user that this is a ported thread.
   *
   * Any interactions with ported threads are reflected on the original threads.
   * Replying to a ported thread ported from Patchset 6 shown on Patchset 10
   * say creates a draft reply associated with Patchset 6, since the user is
   * interacting with the original thread.
   *
   * Only threads with unresolved comments or drafts are ported over.
   * If the thread is associated with either the left patchset or the right
   * patchset, then we filter that ported thread from the return value
   * as it will be rendered by default.
   *
   * If there is no appropriate range for the ported comments, then the backend
   * does not return the range of the ported thread and it becomes a file level
   * thread.
   *
   * If a comment was created with Side=PARENT, then we only show this ported
   * comment if Base is part of the patch range, always on the left side of
   * the diff.
   *
   * @return only the ported threads for the specified file and patch range
   */
  _getPortedCommentThreads(
    file: PatchSetFile,
    patchRange: PatchRange
  ): CommentThread[] {
    const portedComments = this._portedComments[file.path] || [];
    portedComments.push(...(this._portedDrafts[file.path] || []));
    if (file.basePath) {
      portedComments.push(...(this._portedComments[file.basePath] || []));
      portedComments.push(...(this._portedDrafts[file.basePath] || []));
    }
    if (!portedComments.length) return [];

    // when forming threads in diff view, we filter for current patchrange but
    // ported comments will involve comments that may not belong to the
    // current patchrange, so we need to form threads for them using all
    // comments
    const allComments: UIComment[] = this.getAllCommentsForFile(file, true);

    return createCommentThreads(allComments).filter(thread => {
      // Robot comments and drafts are not ported over. A human reply to
      // the robot comment will be ported over, thefore it's possible to
      // have the root comment of the thread not be ported, hence loop over
      // entire thread
      const portedComment = portedComments.find(portedComment =>
        thread.comments.some(c => portedComment.id === c.id)
      );
      if (!portedComment) return false;

      const originalComment = thread.comments.find(
        comment => comment.id === portedComment.id
      )!;

      if (
        (originalComment.line && !portedComment.line) ||
        (originalComment.range && !portedComment.range)
      ) {
        thread.rangeInfoLost = true;
      }

      if (
        isInBaseOfPatchRange(thread.comments[0], patchRange) ||
        isInRevisionOfPatchRange(thread.comments[0], patchRange)
      ) {
        // no need to port this thread as it will be rendered by default
        return false;
      }

      thread.diffSide = Side.RIGHT;
      if (thread.commentSide === CommentSide.PARENT) {
        // TODO(dhruvsri): Add handling for merge parents
        if (
          patchRange.basePatchNum !== ParentPatchSetNum ||
          !!thread.mergeParentNum
        )
          return false;
        thread.diffSide = Side.LEFT;
      }

      if (!isUnresolved(thread) && !isDraftThread(thread)) return false;

      thread.range = portedComment.range;
      thread.line = portedComment.line;
      thread.ported = true;
      return true;
    });
  }

  getThreadsBySideForFile(
    file: PatchSetFile,
    patchRange: PatchRange
  ): CommentThread[] {
    const threads = createCommentThreads(
      this.getCommentsForFile(file, patchRange),
      patchRange
    );
    threads.push(...this._getPortedCommentThreads(file, patchRange));
    return threads;
  }

  /**
   * Get the comments (with drafts and robot comments) for a file and
   * patch-range. Returns an object with left and right properties mapping to
   * arrays of comments in on either side of the patch range for that path.
   *
   * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass
   *
   * @param patchRange The patch-range object containing patchNum
   * and basePatchNum properties to represent the range.
   * @param projectConfig Optional project config object to
   * include in the meta sub-object.
   */
  getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
    const comments = this.getCommentsForPath(file.path, patchRange);
    if (file.basePath) {
      comments.push(...this.getCommentsForPath(file.basePath, patchRange));
    }
    return comments;
  }

  _commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
    return Object.keys(comments).reduce((commentArr: T[], file) => {
      comments[file].forEach(c => commentArr.push({...c}));
      return commentArr;
    }, []);
  }

  /**
   * Computes the number of comment threads in a given file or patch.
   */
  computeCommentThreadCount(
    file: PatchSetFile | PatchNumOnly,
    ignorePatchsetLevelComments?: boolean
  ) {
    let comments: Comment[] = [];
    if (isPatchSetFile(file)) {
      comments = this.getAllCommentsForFile(file);
    } else {
      comments = this._commentObjToArray(
        this.getAllPublishedComments(file.patchNum)
      );
    }
    let threads = createCommentThreads(comments);
    if (ignorePatchsetLevelComments)
      threads = threads.filter(thread => !isPatchsetLevel(thread));
    return threads.length;
  }

  /**
   * Computes a string counting the number of draft comments in the entire
   * change, optionally filtered by path and/or patchNum.
   */
  computeDraftCount(file?: PatchSetFile | PatchNumOnly) {
    if (file && isPatchSetFile(file)) {
      return this.getAllDraftsForFile(file).length;
    }
    const allDrafts = this.getAllDrafts(file && file.patchNum);
    return this._commentObjToArray(allDrafts).length;
  }

  // TODO(dhruvsri): merge with computeDraftCount
  computePortedDraftCount(patchRange: PatchRange, path: string) {
    const threads = this.getThreadsBySideForFile({path}, patchRange);
    return threads.filter(thread => isDraftThread(thread) && thread.ported)
      .length;
  }

  /**
   * @param includeUnmodified Included unmodified status of the file in the
   * comment string or not. For files we opt of chip instead of a string.
   * @param filterPatchset Only count threads which belong to this patchset
   */
  computeCommentsString(
    patchRange?: PatchRange,
    path?: string,
    changeFileInfo?: FileInfo,
    includeUnmodified?: boolean
  ) {
    if (!path) return '';
    if (!patchRange) return '';

    const threads = this.getThreadsBySideForFile({path}, patchRange);
    const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
      .length;
    const unresolvedCount = threads.reduce((cnt, thread) => {
      if (isUnresolved(thread)) cnt += 1;
      return cnt;
    }, 0);

    const commentThreadString = pluralize(commentThreadCount, 'comment');
    const unresolvedString =
      unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;

    const unmodifiedString =
      includeUnmodified && changeFileInfo?.status === 'U' ? 'no changes' : '';

    return (
      commentThreadString +
      // Add a space if both comments and unresolved
      (commentThreadString && unresolvedString ? ' ' : '') +
      // Add parentheses around unresolved if it exists.
      (unresolvedString ? `(${unresolvedString})` : '') +
      (unmodifiedString ? `(${unmodifiedString})` : '')
    );
  }

  /**
   * Computes a number of unresolved comment threads in a given file and path.
   */
  computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
    let comments: Comment[] = [];
    let drafts: Comment[] = [];

    if (isPatchSetFile(file)) {
      comments = this.getAllCommentsForFile(file);
      drafts = this.getAllDraftsForFile(file);
    } else {
      comments = this._commentObjToArray(
        this.getAllPublishedComments(file.patchNum)
      );
    }

    comments = comments.concat(drafts);
    const threads = createCommentThreads(comments);
    const unresolvedThreads = threads.filter(isUnresolved);
    return unresolvedThreads.length;
  }

  getAllThreadsForChange() {
    const comments = this._commentObjToArray(this.getAllComments(true));
    return createCommentThreads(comments);
  }
}

// TODO(TS): move findCommentById out of class
export const _testOnly_findCommentById =
  ChangeComments.prototype.findCommentById;

export const _testOnly_getCommentsForPath =
  ChangeComments.prototype.getCommentsForPath;
@customElement('gr-comment-api')
export class GrCommentApi extends PolymerElement {
  static get template() {
    return htmlTemplate;
  }

  @property({type: Object})
  _changeComments?: ChangeComments;

  private readonly restApiService = appContext.restApiService;

  /**
   * Load all comments (with drafts and robot comments) for the given change
   * number. The returned promise resolves when the comments have loaded, but
   * does not yield the comment data.
   */
  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
    const revision = patchNum || CURRENT;
    const commentsPromise = [
      this.restApiService.getDiffComments(changeNum),
      this.restApiService.getDiffRobotComments(changeNum),
      this.restApiService.getDiffDrafts(changeNum),
      this.restApiService.getPortedComments(changeNum, revision),
      this.restApiService.getPortedDrafts(changeNum, revision),
    ];

    return Promise.all(commentsPromise).then(
      ([comments, robotComments, drafts, portedComments, portedDrafts]) => {
        this._changeComments = new ChangeComments(
          comments,
          // TS 4.0.5 fails without 'as'
          robotComments as PathToRobotCommentsInfoMap | undefined,
          drafts,
          portedComments,
          portedDrafts
        );
        return this._changeComments;
      }
    );
  }

  /**
   * Re-initialize _changeComments with a new ChangeComments object, that
   * uses the previous values for comments and robot comments, but fetches
   * updated draft comments.
   */
  reloadDrafts(changeNum: NumericChangeId) {
    if (!this._changeComments) {
      return this.loadAll(changeNum);
    }
    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
        drafts
      );
      return this._changeComments;
    });
  }

  reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
    if (!this._changeComments) {
      this.loadAll(changeNum);
      return Promise.resolve();
    }
    return Promise.all([
      this.restApiService.getPortedComments(changeNum, patchNum),
      this.restApiService.getPortedDrafts(changeNum, patchNum),
    ]).then(res => {
      if (!this._changeComments) return;
      this._changeComments = this._changeComments.cloneWithUpdatedPortedComments(
        res[0],
        res[1]
      );
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'gr-comment-api': GrCommentApi;
  }
}
