/**
 * @license
 * Copyright (C) 2015 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 '../../../test/common-test-setup-karma.js';
import {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
import './gr-reply-dialog.js';
import {mockPromise, stubStorage} from '../../../test/test-utils.js';
import {SpecialFilePath} from '../../../constants/constants.js';
import {appContext} from '../../../services/app-context.js';
import {addListenerForTest} from '../../../test/test-utils.js';
import {stubRestApi} from '../../../test/test-utils.js';
import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper.js';
import {CODE_REVIEW} from '../../../utils/label-util.js';
import {createAccountWithId} from '../../../test/test-data-generators.js';

const basicFixture = fixtureFromElement('gr-reply-dialog');

function cloneableResponse(status, text) {
  return {
    ok: false,
    status,
    text() {
      return Promise.resolve(text);
    },
    clone() {
      return {
        ok: false,
        status,
        text() {
          return Promise.resolve(text);
        },
      };
    },
  };
}

suite('gr-reply-dialog tests', () => {
  let element;
  let changeNum;
  let patchNum;

  let getDraftCommentStub;
  let setDraftCommentStub;
  let eraseDraftCommentStub;

  let lastId = 0;
  const makeAccount = function() { return {_account_id: lastId++}; };
  const makeGroup = function() { return {id: lastId++}; };

  setup(() => {
    changeNum = 42;
    patchNum = 1;

    stubRestApi('getAccount').returns(Promise.resolve({}));
    stubRestApi('getChange').returns(Promise.resolve({}));
    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));

    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);

    element = basicFixture.instantiate();
    element.change = {
      _number: changeNum,
      owner: {
        _account_id: 999,
        display_name: 'Kermit',
      },
      labels: {
        'Verified': {
          values: {
            '-1': 'Fails',
            ' 0': 'No score',
            '+1': 'Verified',
          },
          default_value: 0,
        },
        'Code-Review': {
          values: {
            '-2': 'Do not submit',
            '-1': 'I would prefer that you didn\'t submit this',
            ' 0': 'No score',
            '+1': 'Looks good to me, but someone else must approve',
            '+2': 'Looks good to me, approved',
          },
          default_value: 0,
        },
      },
    };
    element.patchNum = patchNum;
    element.permittedLabels = {
      'Code-Review': [
        '-1',
        ' 0',
        '+1',
      ],
      'Verified': [
        '-1',
        ' 0',
        '+1',
      ],
    };

    getDraftCommentStub = stubStorage('getDraftComment');
    setDraftCommentStub = stubStorage('setDraftComment');
    eraseDraftCommentStub = stubStorage('eraseDraftComment');

    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
    //     .returns(Promise.resolve({isLatest: true}));

    // Allow the elements created by dom-repeat to be stamped.
    flush();
  });

  function stubSaveReview(jsonResponseProducer) {
    return sinon.stub(
        element,
        '_saveReview')
        .callsFake(review => new Promise((resolve, reject) => {
          try {
            const result = jsonResponseProducer(review) || {};
            const resultStr = JSON_PREFIX + JSON.stringify(result);
            resolve({
              ok: true,
              text() {
                return Promise.resolve(resultStr);
              },
            });
          } catch (err) {
            reject(err);
          }
        }));
  }

  test('default to publishing draft comments with reply', done => {
    // Async tick is needed because iron-selector content is distributed and
    // distributed content requires an observer to be set up.
    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
    flush(() => {
      flush(() => {
        element.draft = 'I wholeheartedly disapprove';

        stubSaveReview(review => {
          assert.deepEqual(review, {
            drafts: 'PUBLISH_ALL_REVISIONS',
            labels: {
              'Code-Review': 0,
              'Verified': 0,
            },
            comments: {
              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
                message: 'I wholeheartedly disapprove',
                unresolved: false,
              }],
            },
            reviewers: [],
          });
          assert.isFalse(element.$.commentList.hidden);
          done();
        });

        // This is needed on non-Blink engines most likely due to the ways in
        // which the dom-repeat elements are stamped.
        flush(() => {
          MockInteractions.tap(element.shadowRoot
              .querySelector('.send'));
        });
      });
    });
  });

  test('modified attention set', done => {
    element.serverConfig = {
      change: {enable_attention_set: true},
    };
    element._newAttentionSet = new Set([314]);
    const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
    MockInteractions.tap(buttonEl);
    flush();

    stubSaveReview(review => {
      assert.isTrue(review.ignore_automatic_attention_set_rules);
      assert.deepEqual(review.add_to_attention_set, [{
        user: 314,
        reason: 'Anonymous replied on the change',
      }]);
      assert.deepEqual(review.remove_from_attention_set, []);
      done();
    });
    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
  });

  function checkComputeAttention(status, userId, reviewerIds, ownerId,
      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
      includeComments = true) {
    const user = {_account_id: userId};
    const reviewers = {base: reviewerIds.map(id => {
      return {_account_id: id};
    })};
    const draftThreads = [
      {comments: []},
    ];
    if (hasDraft) {
      draftThreads[0].comments.push({__draft: true, unresolved: true});
    }
    replyToIds.forEach(id => draftThreads[0].comments.push({
      author: {_account_id: id},
    }));
    const change = {
      owner: {_account_id: ownerId},
      status,
      attention_set: {},
    };
    attSetIds.forEach(id => change.attention_set[id] = {});
    if (uploaderId) {
      change.current_revision = 1;
      change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
    }
    element.change = change;
    element._reviewers = reviewers.base;

    flush();
    const hasDrafts = draftThreads.length > 0;
    element._computeNewAttention(
        user, reviewers, [], change, draftThreads, includeComments, undefined,
        hasDrafts);
    assert.sameMembers([...element._newAttentionSet], expectedIds);
  }

  test('computeNewAttention NEW', () => {
    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
    checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
    checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
    checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
    checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
    checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
    checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
    // If the owner replies, then do not add them.
    checkComputeAttention('NEW', 1, [], 1, [], [], []);
    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
    checkComputeAttention('NEW', 1, [22], 1, [], [], []);

    checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
    checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
    checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
    // with uploader
    checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
    checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
    checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
  });

  test('computeNewAttention MERGED', () => {
    checkComputeAttention('MERGED', null, [], 999, [], [], []);
    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
    checkComputeAttention(
        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
    checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
    checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
    checkComputeAttention('MERGED', 1, [], 1, [], [], []);
    checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
    checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
    checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
    checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
    checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
    checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
    checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
  });

  test('computeNewAttention when adding reviewers', () => {
    const user = {_account_id: 1};
    const reviewers = {base: [
      {_account_id: 1, _pendingAdd: true},
      {_account_id: 2, _pendingAdd: true},
    ]};
    const change = {
      owner: {_account_id: 5},
      status: 'NEW',
      attention_set: {},
    };
    element.change = change;
    element._reviewers = reviewers.base;
    flush();

    element._computeNewAttention(user, reviewers, [], change, [], true);
    assert.sameMembers([...element._newAttentionSet], [1, 2]);

    // If the user votes on the change, then they should not be added to the
    // attention set, even if they have just added themselves as reviewer.
    // But voting should also add the owner (5).
    const labelsChanged = true;
    element._computeNewAttention(
        user, reviewers, [], change, [], true, labelsChanged);
    assert.sameMembers([...element._newAttentionSet], [2, 5]);
  });

  test('computeNewAttention when sending wip change for review', () => {
    const reviewers = {base: [
      {_account_id: 2},
      {_account_id: 3},
    ]};
    const change = {
      owner: {_account_id: 1},
      status: 'NEW',
      attention_set: {},
    };
    element.change = change;
    element._reviewers = reviewers.base;
    flush();

    // For an active change there is no reason to add anyone to the set.
    let user = {_account_id: 1};
    element._computeNewAttention(user, reviewers, [], change, [], false);
    assert.sameMembers([...element._newAttentionSet], []);

    // If the change is "work in progress" and the owner sends a reply, then
    // add all reviewers.
    element.canBeStarted = true;
    flush();
    user = {_account_id: 1};
    element._computeNewAttention(user, reviewers, [], change, [], false);
    assert.sameMembers([...element._newAttentionSet], [2, 3]);

    // ... but not when someone else replies.
    user = {_account_id: 4};
    element._computeNewAttention(user, reviewers, [], change, [], false);
    assert.sameMembers([...element._newAttentionSet], []);
  });

  test('computeNewAttentionAccounts', () => {
    element._reviewers = [
      {_account_id: 123, display_name: 'Ernie'},
      {_account_id: 321, display_name: 'Bert'},
    ];
    element._ccs = [
      {_account_id: 7, display_name: 'Elmo'},
    ];
    const compute = (currentAtt, newAtt) =>
      element._computeNewAttentionAccounts(
          undefined, new Set(currentAtt), new Set(newAtt))
          .map(a => a._account_id);

    assert.sameMembers(compute([], []), []);
    assert.sameMembers(compute([], [999]), [999]);
    assert.sameMembers(compute([999], []), []);
    assert.sameMembers(compute([999], [999]), []);
    assert.sameMembers(compute([123, 321], [999]), [999]);
    assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
  });

  test('_computeCommentAccounts', () => {
    element.change = {
      labels: {
        'Code-Review': {
          all: [
            {_account_id: 1, value: 0},
            {_account_id: 2, value: 1},
            {_account_id: 3, value: 2},
          ],
          values: {
            '-2': 'Do not submit',
            '-1': 'I would prefer that you didnt submit this',
            ' 0': 'No score',
            '+1': 'Looks good to me, but someone else must approve',
            '+2': 'Looks good to me, approved',
          },
        },
      },
    };
    const threads = [
      {
        comments: [
          {author: {_account_id: 1}, unresolved: false},
          {author: {_account_id: 2}, unresolved: true},
        ],
      },
      {
        comments: [
          {author: {_account_id: 3}, unresolved: false},
          {author: {_account_id: 4}, unresolved: false},
        ],
      },
    ];
    const actualAccounts = [...element._computeCommentAccounts(threads)];
    // Account 3 is not included, because the comment is resolved *and* they
    // have given the highest possible vote on the Code-Review label.
    assert.sameMembers(actualAccounts, [1, 2, 4]);
  });

  test('toggle resolved checkbox', done => {
    // Async tick is needed because iron-selector content is distributed and
    // distributed content requires an observer to be set up.
    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
    const checkboxEl = element.shadowRoot.querySelector(
        '#resolvedPatchsetLevelCommentCheckbox');
    MockInteractions.tap(checkboxEl);
    flush(() => {
      flush(() => {
        element.draft = 'I wholeheartedly disapprove';

        stubSaveReview(review => {
          assert.deepEqual(review, {
            drafts: 'PUBLISH_ALL_REVISIONS',
            labels: {
              'Code-Review': 0,
              'Verified': 0,
            },
            comments: {
              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
                message: 'I wholeheartedly disapprove',
                unresolved: true,
              }],
            },
            reviewers: [],
          });
          done();
        });

        // This is needed on non-Blink engines most likely due to the ways in
        // which the dom-repeat elements are stamped.
        flush(() => {
          MockInteractions.tap(element.shadowRoot
              .querySelector('.send'));
        });
      });
    });
  });

  test('keep draft comments with reply', done => {
    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
    assert.equal(element._includeComments, false);

    // Async tick is needed because iron-selector content is distributed and
    // distributed content requires an observer to be set up.
    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
    flush(() => {
      flush(() => {
        element.draft = 'I wholeheartedly disapprove';

        stubSaveReview(review => {
          assert.deepEqual(review, {
            drafts: 'KEEP',
            labels: {
              'Code-Review': 0,
              'Verified': 0,
            },
            comments: {
              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
                message: 'I wholeheartedly disapprove',
                unresolved: false,
              }],
            },
            reviewers: [],
          });
          assert.isTrue(element.$.commentList.hidden);
          done();
        });

        // This is needed on non-Blink engines most likely due to the ways in
        // which the dom-repeat elements are stamped.
        flush(() => {
          MockInteractions.tap(element.shadowRoot
              .querySelector('.send'));
        });
      });
    });
  });

  test('label picker', done => {
    element.draft = 'I wholeheartedly disapprove';
    stubSaveReview(review => {
      assert.deepEqual(review, {
        drafts: 'PUBLISH_ALL_REVISIONS',
        labels: {
          'Code-Review': -1,
          'Verified': -1,
        },
        comments: {
          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
            message: 'I wholeheartedly disapprove',
            unresolved: false,
          }],
        },
        reviewers: [],
      });
    });

    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
      return {
        'Code-Review': -1,
        'Verified': -1,
      };
    });

    element.addEventListener('send', () => {
      // Flush to ensure properties are updated.
      flush(() => {
        assert.isFalse(element.disabled,
            'Element should be enabled when done sending reply.');
        assert.equal(element.draft.length, 0);
        done();
      });
    });

    // This is needed on non-Blink engines most likely due to the ways in
    // which the dom-repeat elements are stamped.
    flush(() => {
      MockInteractions.tap(element.shadowRoot
          .querySelector('.send'));
      assert.isTrue(element.disabled);
    });
  });

  test('getlabelValue returns value', done => {
    flush(() => {
      element.shadowRoot
          .querySelector('gr-label-scores')
          .shadowRoot
          .querySelector(`gr-label-score-row[name="Verified"]`)
          .setSelectedValue(-1);
      assert.equal('-1', element.getLabelValue('Verified'));
      done();
    });
  });

  test('getlabelValue when no score is selected', done => {
    flush(() => {
      element.shadowRoot
          .querySelector('gr-label-scores')
          .shadowRoot
          .querySelector(`gr-label-score-row[name="Code-Review"]`)
          .setSelectedValue(-1);
      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
      done();
    });
  });

  test('setlabelValue', done => {
    element._account = {_account_id: 1};
    flush(() => {
      const label = 'Verified';
      const value = '+1';
      element.setLabelValue(label, value);

      const labels = element.$.labelScores.getLabelValues();
      assert.deepEqual(labels, {
        'Code-Review': 0,
        'Verified': 1,
      });
      done();
    });
  });

  function getActiveElement() {
    return IronOverlayManager.deepActiveElement;
  }

  function isVisible(el) {
    assert.ok(el);
    return getComputedStyle(el).getPropertyValue('display') != 'none';
  }

  function overlayObserver(mode) {
    return new Promise(resolve => {
      function listener() {
        element.removeEventListener('iron-overlay-' + mode, listener);
        resolve();
      }
      element.addEventListener('iron-overlay-' + mode, listener);
    });
  }

  function isFocusInsideElement(element) {
    // In Polymer 2 focused element either <paper-input> or nested
    // native input <input> element depending on the current focus
    // in browser window.
    // For example, the focus is changed if the developer console
    // get a focus.
    let activeElement = getActiveElement();
    while (activeElement) {
      if (activeElement === element) {
        return true;
      }
      if (activeElement.parentElement) {
        activeElement = activeElement.parentElement;
      } else {
        activeElement = activeElement.getRootNode().host;
      }
    }
    return false;
  }

  async function testConfirmationDialog(cc) {
    const yesButton = element
        .shadowRoot
        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
    const noButton = element
        .shadowRoot
        .querySelector('.reviewerConfirmationButtons gr-button:last-child');

    element._ccPendingConfirmation = null;
    element._reviewerPendingConfirmation = null;
    flush();
    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));

    // Cause the confirmation dialog to display.
    let observer = overlayObserver('opened');
    const group = {
      id: 'id',
      name: 'name',
    };
    if (cc) {
      element._ccPendingConfirmation = {
        group,
        count: 10,
      };
    } else {
      element._reviewerPendingConfirmation = {
        group,
        count: 10,
      };
    }
    flush();

    if (cc) {
      assert.deepEqual(
          element._ccPendingConfirmation,
          element._pendingConfirmationDetails);
    } else {
      assert.deepEqual(
          element._reviewerPendingConfirmation,
          element._pendingConfirmationDetails);
    }

    await observer;
    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
    observer = overlayObserver('closed');
    const expected = 'Group name has 10 members';
    assert.notEqual(
        element.$.reviewerConfirmationOverlay.innerText
            .indexOf(expected),
        -1);
    MockInteractions.tap(noButton); // close the overlay

    await observer;
    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));

    // We should be focused on account entry input.
    assert.isTrue(
        isFocusInsideElement(
            element.$.reviewers.$.entry.$.input.$.input
        )
    );

    // No reviewer/CC should have been added.
    assert.equal(element.$.ccs.additions().length, 0);
    assert.equal(element.$.reviewers.additions().length, 0);

    // Reopen confirmation dialog.
    observer = overlayObserver('opened');
    if (cc) {
      element._ccPendingConfirmation = {
        group,
        count: 10,
      };
    } else {
      element._reviewerPendingConfirmation = {
        group,
        count: 10,
      };
    }

    await observer;
    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
    observer = overlayObserver('closed');
    MockInteractions.tap(yesButton); // Confirm the group.

    await observer;
    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
    const additions = cc ?
      element.$.ccs.additions() :
      element.$.reviewers.additions();
    assert.deepEqual(
        additions,
        [
          {
            group: {
              id: 'id',
              name: 'name',
              confirmed: true,
              _group: true,
              _pendingAdd: true,
            },
          },
        ]);

    // We should be focused on account entry input.
    if (cc) {
      assert.isTrue(
          isFocusInsideElement(
              element.$.ccs.$.entry.$.input.$.input
          )
      );
    } else {
      assert.isTrue(
          isFocusInsideElement(
              element.$.reviewers.$.entry.$.input.$.input
          )
      );
    }
  }

  test('cc confirmation', async () => {
    testConfirmationDialog(true);
  });

  test('reviewer confirmation', async () => {
    testConfirmationDialog(false);
  });

  test('_getStorageLocation', () => {
    const actual = element._getStorageLocation();
    assert.equal(actual.changeNum, changeNum);
    assert.equal(actual.patchNum, '@change');
    assert.equal(actual.path, '@change');
  });

  test('_reviewersMutated when account-text-change is fired from ccs', () => {
    flush();
    assert.isFalse(element._reviewersMutated);
    assert.isTrue(element.$.ccs.allowAnyInput);
    assert.isFalse(element.shadowRoot
        .querySelector('#reviewers').allowAnyInput);
    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
        {bubbles: true, composed: true}));
    assert.isTrue(element._reviewersMutated);
  });

  test('gets draft from storage on open', () => {
    const storedDraft = 'hello world';
    getDraftCommentStub.returns({message: storedDraft});
    element.open();
    assert.isTrue(getDraftCommentStub.called);
    assert.equal(element.draft, storedDraft);
  });

  test('gets draft from storage even when text is already present', () => {
    const storedDraft = 'hello world';
    getDraftCommentStub.returns({message: storedDraft});
    element.draft = 'foo bar';
    element.open();
    assert.isTrue(getDraftCommentStub.called);
    assert.equal(element.draft, storedDraft);
  });

  test('blank if no stored draft', () => {
    getDraftCommentStub.returns(null);
    element.draft = 'foo bar';
    element.open();
    assert.isTrue(getDraftCommentStub.called);
    assert.equal(element.draft, '');
  });

  test('does not check stored draft when quote is present', () => {
    const storedDraft = 'hello world';
    const quote = '> foo bar';
    getDraftCommentStub.returns({message: storedDraft});
    element.quote = quote;
    element.open();
    assert.isFalse(getDraftCommentStub.called);
    assert.equal(element.draft, quote);
    assert.isNotOk(element.quote);
  });

  test('updates stored draft on edits', () => {
    const firstEdit = 'hello';
    const location = element._getStorageLocation();

    element.draft = firstEdit;
    element.storeTask.flush();

    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));

    element.draft = '';
    element.storeTask.flush();

    assert.isTrue(eraseDraftCommentStub.calledWith(location));
  });

  test('400 converts to human-readable server-error', done => {
    stubRestApi('saveChangeReview').callsFake(
        (changeNum, patchNum, review, errFn) => {
          errFn(cloneableResponse(
              400,
              '....{"reviewers":{"id1":{"error":"human readable"}}}'
          ));
          return Promise.resolve(undefined);
        }
    );

    const listener = event => {
      if (event.target !== document) return;
      event.detail.response.text().then(body => {
        if (body === 'human readable') {
          done();
        }
      });
    };
    addListenerForTest(document, 'server-error', listener);

    flush(() => { element.send(); });
  });

  test('non-json 400 is treated as a normal server-error', done => {
    stubRestApi('saveChangeReview').callsFake(
        (changeNum, patchNum, review, errFn) => {
          errFn(cloneableResponse(400, 'Comment validation error!'));
          return Promise.resolve(undefined);
        }
    );

    const listener = event => {
      if (event.target !== document) return;
      event.detail.response.text().then(body => {
        if (body === 'Comment validation error!') {
          done();
        }
      });
    };
    addListenerForTest(document, 'server-error', listener);

    // Async tick is needed because iron-selector content is distributed and
    // distributed content requires an observer to be set up.
    flush(() => { element.send(); });
  });

  test('filterReviewerSuggestion', () => {
    const owner = makeAccount();
    const reviewer1 = makeAccount();
    const reviewer2 = makeGroup();
    const cc1 = makeAccount();
    const cc2 = makeGroup();
    let filter = element._filterReviewerSuggestionGenerator(false);

    element._owner = owner;
    element._reviewers = [reviewer1, reviewer2];
    element._ccs = [cc1, cc2];

    assert.isTrue(filter({account: makeAccount()}));
    assert.isTrue(filter({group: makeGroup()}));

    // Owner should be excluded.
    assert.isFalse(filter({account: owner}));

    // Existing and pending reviewers should be excluded when isCC = false.
    assert.isFalse(filter({account: reviewer1}));
    assert.isFalse(filter({group: reviewer2}));

    filter = element._filterReviewerSuggestionGenerator(true);

    // Existing and pending CCs should be excluded when isCC = true;.
    assert.isFalse(filter({account: cc1}));
    assert.isFalse(filter({group: cc2}));
  });

  test('_focusOn', async () => {
    sinon.spy(element, '_chooseFocusTarget');
    element._focusOn();
    await flush();
    assert.equal(element._chooseFocusTarget.callCount, 1);
    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
    assert.equal(element.shadowRoot.activeElement.id, 'textarea');

    element._focusOn(element.FocusTarget.ANY);
    await flush();
    assert.equal(element._chooseFocusTarget.callCount, 2);
    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
    assert.equal(element.shadowRoot.activeElement.id, 'textarea');

    element._focusOn(element.FocusTarget.BODY);
    await flush();
    assert.equal(element._chooseFocusTarget.callCount, 2);
    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
    assert.equal(element.shadowRoot.activeElement.id, 'textarea');

    element._focusOn(element.FocusTarget.REVIEWERS);
    await flush();
    assert.equal(element._chooseFocusTarget.callCount, 2);
    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
    assert.equal(element.shadowRoot.activeElement.id, 'reviewers');

    element._focusOn(element.FocusTarget.CCS);
    await flush();
    assert.equal(element._chooseFocusTarget.callCount, 2);
    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
    assert.equal(element.shadowRoot.activeElement.id, 'ccs');
  });

  test('_chooseFocusTarget', () => {
    element._account = undefined;
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.BODY);

    element._account = {_account_id: 1};
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.BODY);

    element.change.owner = {_account_id: 2};
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.BODY);

    element.change.owner._account_id = 1;
    element.change._reviewers = null;
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);

    element._reviewers = [];
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);

    element._reviewers.push({});
    assert.strictEqual(
        element._chooseFocusTarget(), element.FocusTarget.BODY);
  });

  test('only send labels that have changed', done => {
    flush(() => {
      stubSaveReview(review => {
        assert.deepEqual(review.labels, {
          'Code-Review': 0,
          'Verified': -1,
        });
      });

      element.addEventListener('send', () => {
        done();
      });
      // Without wrapping this test in flush(), the below two calls to
      // MockInteractions.tap() cause a race in some situations in shadow DOM.
      // The send button can be tapped before the others, causing the test to
      // fail.

      element.shadowRoot
          .querySelector('gr-label-scores').shadowRoot
          .querySelector(
              'gr-label-score-row[name="Verified"]')
          .setSelectedValue(-1);
      MockInteractions.tap(element.shadowRoot
          .querySelector('.send'));
    });
  });

  test('_processReviewerChange', () => {
    const mockIndexSplices = function(toRemove) {
      return [{
        removed: [toRemove],
      }];
    };

    element._processReviewerChange(
        mockIndexSplices(makeAccount()), 'REVIEWER');
    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
  });

  test('_purgeReviewersPendingRemove', () => {
    const removeStub = sinon.stub(element, '_removeAccount');
    const mock = function() {
      element._reviewersPendingRemove = {
        CC: [makeAccount()],
        REVIEWER: [makeAccount(), makeAccount()],
      };
    };
    const checkObjEmpty = function(obj) {
      for (const prop of Object.keys(obj)) {
        if (obj[prop].length) { return false; }
      }
      return true;
    };
    mock();
    element._purgeReviewersPendingRemove(true); // Cancel
    assert.isFalse(removeStub.called);
    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));

    mock();
    element._purgeReviewersPendingRemove(false); // Submit
    assert.isTrue(removeStub.called);
    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
  });

  test('_removeAccount', done => {
    stubRestApi('removeChangeReviewer')
        .returns(Promise.resolve({ok: true}));
    const arr = [makeAccount(), makeAccount()];
    element.change.reviewers = {
      REVIEWER: arr.slice(),
    };

    element._removeAccount(arr[1], 'REVIEWER').then(() => {
      assert.equal(element.change.reviewers.REVIEWER.length, 1);
      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
      done();
    });
  });

  test('moving from cc to reviewer', () => {
    element._reviewersPendingRemove = {
      CC: [],
      REVIEWER: [],
    };
    flush();

    const reviewer1 = makeAccount();
    const reviewer2 = makeAccount();
    const reviewer3 = makeAccount();
    const cc1 = makeAccount();
    const cc2 = makeAccount();
    const cc3 = makeAccount();
    const cc4 = makeAccount();
    element._reviewers = [reviewer1, reviewer2, reviewer3];
    element._ccs = [cc1, cc2, cc3, cc4];
    element.push('_reviewers', cc1);
    flush();

    assert.deepEqual(element._reviewers,
        [reviewer1, reviewer2, reviewer3, cc1]);
    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);

    element.push('_reviewers', cc4, cc3);
    flush();

    assert.deepEqual(element._reviewers,
        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
    assert.deepEqual(element._ccs, [cc2]);
    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
  });

  test('update attention section when reviewers and ccs change', () => {
    element._account = makeAccount();
    element._reviewers = [makeAccount(), makeAccount()];
    element._ccs = [makeAccount(), makeAccount()];
    element.draftCommentThreads = [];
    const modifyButton =
        element.shadowRoot.querySelector('.edit-attention-button');
    MockInteractions.tap(modifyButton);
    flush();

    // "Modify" button disabled, because "Send" button is disabled.
    assert.isFalse(element._attentionExpanded);
    element.draft = 'a test comment';
    MockInteractions.tap(modifyButton);
    flush();
    assert.isTrue(element._attentionExpanded);

    let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
        '.attention-detail gr-account-label'));
    assert.equal(accountLabels.length, 5);

    element.push('_reviewers', makeAccount());
    element.push('_ccs', makeAccount());
    flush();

    // The 'attention modified' section collapses and resets when reviewers or
    // ccs change.
    assert.isFalse(element._attentionExpanded);

    MockInteractions.tap(
        element.shadowRoot.querySelector('.edit-attention-button'));
    flush();

    assert.isTrue(element._attentionExpanded);
    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
        '.attention-detail gr-account-label'));
    assert.equal(accountLabels.length, 7);

    element.pop('_reviewers', makeAccount());
    element.pop('_reviewers', makeAccount());
    element.pop('_ccs', makeAccount());
    element.pop('_ccs', makeAccount());

    MockInteractions.tap(
        element.shadowRoot.querySelector('.edit-attention-button'));
    flush();

    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
        '.attention-detail gr-account-label'));
    assert.equal(accountLabels.length, 3);
  });

  test('moving from reviewer to cc', () => {
    element._reviewersPendingRemove = {
      CC: [],
      REVIEWER: [],
    };
    flush();

    const reviewer1 = makeAccount();
    const reviewer2 = makeAccount();
    const reviewer3 = makeAccount();
    const cc1 = makeAccount();
    const cc2 = makeAccount();
    const cc3 = makeAccount();
    const cc4 = makeAccount();
    element._reviewers = [reviewer1, reviewer2, reviewer3];
    element._ccs = [cc1, cc2, cc3, cc4];
    element.push('_ccs', reviewer1);
    flush();

    assert.deepEqual(element._reviewers,
        [reviewer2, reviewer3]);
    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);

    element.push('_ccs', reviewer3, reviewer2);
    flush();

    assert.deepEqual(element._reviewers, []);
    assert.deepEqual(element._ccs,
        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
        [reviewer1, reviewer3, reviewer2]);
  });

  test('migrate reviewers between states', async () => {
    element._reviewersPendingRemove = {
      CC: [],
      REVIEWER: [],
    };
    flush();
    const reviewers = element.$.reviewers;
    const ccs = element.$.ccs;
    const reviewer1 = makeAccount();
    const reviewer2 = makeAccount();
    const cc1 = makeAccount();
    const cc2 = makeAccount();
    const cc3 = makeAccount();
    element._reviewers = [reviewer1, reviewer2];
    element._ccs = [cc1, cc2, cc3];

    const mutations = [];

    stubSaveReview(review => mutations.push(...review.reviewers));

    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
      mutations.push({state: 'REMOVED', account});
      return Promise.resolve();
    });

    // Remove and add to other field.
    reviewers.dispatchEvent(
        new CustomEvent('remove', {
          detail: {account: reviewer1},
          composed: true, bubbles: true,
        }));
    ccs.$.entry.dispatchEvent(
        new CustomEvent('add', {
          detail: {value: {account: reviewer1}},
          composed: true, bubbles: true,
        }));
    ccs.dispatchEvent(
        new CustomEvent('remove', {
          detail: {account: cc1},
          composed: true, bubbles: true,
        }));
    ccs.dispatchEvent(
        new CustomEvent('remove', {
          detail: {account: cc3},
          composed: true, bubbles: true,
        }));
    reviewers.$.entry.dispatchEvent(
        new CustomEvent('add', {
          detail: {value: {account: cc1}},
          composed: true, bubbles: true,
        }));

    // Add to other field without removing from former field.
    // (Currently not possible in UI, but this is a good consistency check).
    reviewers.$.entry.dispatchEvent(
        new CustomEvent('add', {
          detail: {value: {account: cc2}},
          composed: true, bubbles: true,
        }));
    ccs.$.entry.dispatchEvent(
        new CustomEvent('add', {
          detail: {value: {account: reviewer2}},
          composed: true, bubbles: true,
        }));
    const mapReviewer = function(reviewer, opt_state) {
      const result = {reviewer: reviewer._account_id};
      if (opt_state) {
        result.state = opt_state;
      }
      return result;
    };

    // Send and purge and verify moves, delete cc3.
    await element.send()
        .then(keepReviewers =>
          element._purgeReviewersPendingRemove(false, keepReviewers));
    expect(mutations).to.have.lengthOf(5);
    expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
    expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
    expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
  });

  test('emits cancel on esc key', () => {
    const cancelHandler = sinon.spy();
    element.addEventListener('cancel', cancelHandler);
    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
    flush();

    assert.isTrue(cancelHandler.called);
  });

  test('should not send on enter key', () => {
    stubSaveReview(() => undefined);
    element.addEventListener('send', () => assert.fail('wrongly called'));
    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
    flush();
  });

  test('emit send on ctrl+enter key', done => {
    stubSaveReview(() => undefined);
    element.addEventListener('send', () => done());
    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
    flush();
  });

  test('_computeMessagePlaceholder', () => {
    assert.equal(
        element._computeMessagePlaceholder(false),
        'Say something nice...');
    assert.equal(
        element._computeMessagePlaceholder(true),
        'Add a note for your reviewers...');
  });

  test('_computeSendButtonLabel', () => {
    assert.equal(
        element._computeSendButtonLabel(false),
        'Send');
    assert.equal(
        element._computeSendButtonLabel(true),
        'Send and Start review');
  });

  test('_handle400Error reviewers and CCs', done => {
    const error1 = 'error 1';
    const error2 = 'error 2';
    const error3 = 'error 3';
    const text = ')]}\'' + JSON.stringify({
      reviewers: {
        username1: {
          input: 'username1',
          error: error1,
        },
        username2: {
          input: 'username2',
          error: error2,
        },
        username3: {
          input: 'username3',
          error: error3,
        },
      },
    });
    const listener = e => {
      e.detail.response.text().then(text => {
        assert.equal(text, [error1, error2, error3].join(', '));
        done();
      });
    };
    addListenerForTest(document, 'server-error', listener);
    element._handle400Error(cloneableResponse(400, text));
  });

  test('fires height change when the drafts comments load', done => {
    // Flush DOM operations before binding to the autogrow event so we don't
    // catch the events fired from the initial layout.
    flush(() => {
      const autoGrowHandler = sinon.stub();
      element.addEventListener('autogrow', autoGrowHandler);
      element.draftCommentThreads = [];
      flush(() => {
        assert.isTrue(autoGrowHandler.called);
        done();
      });
    });
  });

  suite('start review and save buttons', () => {
    let sendStub;

    setup(() => {
      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
      element.canBeStarted = true;
      // Flush to make both Start/Save buttons appear in DOM.
      flush();
    });

    test('start review sets ready', () => {
      MockInteractions.tap(element.shadowRoot
          .querySelector('.send'));
      flush();
      assert.isTrue(sendStub.calledWith(true, true));
    });

    test('save review doesn\'t set ready', () => {
      MockInteractions.tap(element.shadowRoot
          .querySelector('.save'));
      flush();
      assert.isTrue(sendStub.calledWith(true, false));
    });
  });

  test('buttons disabled until all API calls are resolved', () => {
    stubSaveReview(review => {
      return {ready: true};
    });
    return element.send(true, true).then(() => {
      assert.isFalse(element.disabled);
    });
  });

  suite('error handling', () => {
    const expectedDraft = 'draft';
    const expectedError = new Error('test');

    setup(() => {
      element.draft = expectedDraft;
    });

    function assertDialogOpenAndEnabled() {
      assert.strictEqual(expectedDraft, element.draft);
      assert.isFalse(element.disabled);
    }

    test('error occurs in _saveReview', () => {
      stubSaveReview(review => {
        throw expectedError;
      });
      return element.send(true, true).catch(err => {
        assert.strictEqual(expectedError, err);
        assertDialogOpenAndEnabled();
      });
    });

    suite('pending diff drafts?', () => {
      test('yes', async () => {
        const promise = mockPromise();
        const refreshSpy = sinon.spy();
        element.addEventListener('comment-refresh', refreshSpy);
        stubRestApi('hasPendingDiffDrafts').returns(true);
        stubRestApi('awaitPendingDiffDrafts').returns(promise);

        element.open();

        assert.isFalse(refreshSpy.called);
        assert.isTrue(element._savingComments);

        promise.resolve();
        await flush();

        assert.isTrue(refreshSpy.called);
        assert.isFalse(element._savingComments);
      });

      test('no', () => {
        stubRestApi('hasPendingDiffDrafts').returns(false);
        element.open();
        assert.isFalse(element._savingComments);
      });
    });
  });

  test('_computeSendButtonDisabled_canBeStarted', () => {
    // Mock canBeStarted
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ true,
        /* draftCommentThreads= */ [],
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_allFalse', () => {
    // Mock everything false
    assert.isTrue(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ [],
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_draftCommentsSend', () => {
    // Mock nonempty comment draft array, with sending comments.
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ true,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
    // Mock nonempty comment draft array, without sending comments.
    assert.isTrue(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_changeMessage', () => {
    // Mock nonempty change message.
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ {},
        /* text= */ 'test',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_reviewersChanged', () => {
    // Mock reviewers mutated.
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ {},
        /* text= */ '',
        /* reviewersMutated= */ true,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_labelsChanged', () => {
    // Mock labels changed.
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ {},
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ true,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_dialogDisabled', () => {
    // Whole dialog is disabled.
    assert.isTrue(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ {},
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ true,
        /* includeComments= */ false,
        /* disabled= */ true,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ makeAccount()
    ));
  });

  test('_computeSendButtonDisabled_existingVote', async () => {
    const account = createAccountWithId();
    element.change.labels[CODE_REVIEW].all = [account];
    await flush();

    // User has already voted.
    assert.isFalse(element._computeSendButtonDisabled(
        /* canBeStarted= */ false,
        /* draftCommentThreads= */ {},
        /* text= */ '',
        /* reviewersMutated= */ false,
        /* labelsChanged= */ false,
        /* includeComments= */ false,
        /* disabled= */ false,
        /* commentEditing= */ false,
        /* change= */ element.change,
        /* account= */ account
    ));
  });

  test('_submit blocked when no mutations exist', async () => {
    const sendStub = sinon.stub(element, 'send').returns(Promise.resolve());
    // Stub the below function to avoid side effects from the send promise
    // resolving.
    sinon.stub(element, '_purgeReviewersPendingRemove');
    element.account = makeAccount();
    element.draftCommentThreads = [];
    await flush();

    MockInteractions.tap(element.shadowRoot
        .querySelector('gr-button.send'));
    assert.isFalse(sendStub.called);

    element.draftCommentThreads = [{comments: [
      {__draft: true, path: 'test', line: 1, patch_set: 1},
    ]}];
    await flush();

    MockInteractions.tap(element.shadowRoot
        .querySelector('gr-button.send'));
    assert.isTrue(sendStub.called);
  });

  test('getFocusStops', async () => {
    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
    // computed to false.
    element.draftCommentThreads = [];
    element.account = makeAccount();
    await flush();

    assert.equal(element.getFocusStops().end, element.$.cancelButton);
    element.draftCommentThreads = [
      {comments: [{__draft: true, path: 'test', line: 1, patch_set: 1}]},
    ];
    await flush();

    assert.equal(element.getFocusStops().end, element.$.sendButton);
  });

  test('setPluginMessage', () => {
    element.setPluginMessage('foo');
    assert.equal(element.$.pluginMessage.textContent, 'foo');
  });
});

