/**
 * @license
 * Copyright (C) 2018 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 './gr-diff-host.js';
import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
import {createCommentThreads} from '../../../utils/comment-util.js';
import {Side, createDefaultDiffPrefs} from '../../../constants/constants.js';
import {createChange} from '../../../test/test-data-generators.js';
import {CoverageType} from '../../../types/types.js';
import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';

const basicFixture = fixtureFromElement('gr-diff-host');

suite('gr-diff-host tests', () => {
  let element;

  let loggedIn;

  setup(async () => {
    loggedIn = false;
    stubRestApi('getLoggedIn').callsFake(() => Promise.resolve(loggedIn));
    element = basicFixture.instantiate();
    element.changeNum = 123;
    element.path = 'some/path';
    sinon.stub(element.reporting, 'time');
    sinon.stub(element.reporting, 'timeEnd');
    await flush();
  });

  suite('plugin layers', () => {
    const pluginLayers = [{annotate: () => {}}, {annotate: () => {}}];
    setup(() => {
      element = basicFixture.instantiate();
      sinon.stub(element.jsAPI, 'getDiffLayers').returns(pluginLayers);
      element.changeNum = 123;
      element.path = 'some/path';
    });
    test('plugin layers requested', async () => {
      element.patchRange = {};
      element.change = createChange();
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      await element.reload();
      assert(element.jsAPI.getDiffLayers.called);
    });
  });

  test('thread-discard handling', () => {
    const threads = createCommentThreads([
      {
        id: 4711,
        diffSide: Side.LEFT,
        updated: '2015-12-20 15:01:20.396000000',
        patch_set: 1,
        path: 'some/path',
      },
      {
        id: 42,
        diffSide: Side.LEFT,
        updated: '2017-12-20 15:01:20.396000000',
        patch_set: 1,
        path: 'some/path',
      },
    ]);
    element._parentIndex = 1;
    element.changeNum = 2;
    element.path = 'some/path';
    element.projectName = 'Some project';
    const threadEls = threads.map(
        thread => {
          const threadEl = element._createThreadElement(thread);
          // Polymer 2 doesn't fire ready events and doesn't execute
          // observers if element is not added to the Dom.
          // See https://github.com/Polymer/old-docs-site/issues/2322
          // and https://github.com/Polymer/polymer/issues/4526
          element._attachThreadElement(threadEl);
          return threadEl;
        });
    assert.equal(threadEls.length, 2);
    assert.equal(threadEls[0].comments[0].id, 4711);
    assert.equal(threadEls[1].comments[0].id, 42);
    for (const threadEl of threadEls) {
      element.appendChild(threadEl);
    }

    threadEls[0].dispatchEvent(
        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
    const attachedThreads = element.querySelectorAll('gr-comment-thread');
    assert.equal(attachedThreads.length, 1);
    assert.equal(attachedThreads[0].comments[0].id, 42);
  });

  suite('render reporting', () => {
    test('starts total and content timer on render-start', done => {
      element.dispatchEvent(
          new CustomEvent('render-start', {bubbles: true, composed: true}));
      assert.isTrue(element.reporting.time.calledWithExactly(
          'Diff Total Render'));
      assert.isTrue(element.reporting.time.calledWithExactly(
          'Diff Content Render'));
      done();
    });

    test('ends content timer on render-content', () => {
      element.dispatchEvent(
          new CustomEvent('render-content', {bubbles: true, composed: true}));
      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
          'Diff Content Render'));
    });

    test('ends total and syntax timer after syntax layer', async () => {
      sinon.stub(element.reporting, 'diffViewContentDisplayed');
      let notifySyntaxProcessed;
      sinon.stub(element.syntaxLayer, 'process').returns(
          new Promise(resolve => {
            notifySyntaxProcessed = resolve;
          })
      );
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      element.patchRange = {};
      element.change = createChange();
      element.prefs = createDefaultDiffPrefs();
      element.reload(true);
      // Multiple cascading microtasks are scheduled.
      await flush();
      notifySyntaxProcessed();
      // Multiple cascading microtasks are scheduled.
      await flush();
      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
          'Diff Total Render'));
      assert.isTrue(element.reporting.timeEnd.calledWithExactly(
          'Diff Syntax Render'));
      assert.isTrue(element.reporting.diffViewContentDisplayed.called);
    });

    test('ends total timer w/ no syntax layer processing', async () => {
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      element.patchRange = {};
      element.change = createChange();
      element.reload();
      // Multiple cascading microtasks are scheduled.
      await flush();
      await flush();
      // Reporting can be called with other parameters (ex. PluginsLoaded),
      // but only 'Diff Total Render' is important in this test.
      assert.equal(
          element.reporting.timeEnd.getCalls()
              .filter(call => call.calledWithExactly('Diff Total Render'))
              .length,
          1);
    });

    test('completes reload promise after syntax layer processing', async () => {
      let notifySyntaxProcessed;
      sinon.stub(element.syntaxLayer, 'process').returns(new Promise(
          resolve => {
            notifySyntaxProcessed = resolve;
          }));
      stubRestApi('getDiff').returns(
          Promise.resolve({content: []}));
      element.patchRange = {};
      element.change = createChange();
      let reloadComplete = false;
      element.prefs = createDefaultDiffPrefs();
      element.reload().then(() => {
        reloadComplete = true;
      });
      // Multiple cascading microtasks are scheduled.
      await flush();
      assert.isFalse(reloadComplete);
      notifySyntaxProcessed();
      // Assert after the notification task is processed.
      await flush();
      assert.isTrue(reloadComplete);
    });
  });

  test('reload() cancels before network resolves', () => {
    const cancelStub = sinon.stub(element.$.diff, 'cancel');

    // Stub the network calls into requests that never resolve.
    sinon.stub(element, '_getDiff').callsFake(() => new Promise(() => {}));
    element.patchRange = {};
    element.change = createChange();

    // Needs to be set to something first for it to cancel.
    element.diff = {
      content: [{
        a: ['foo'],
      }],
    };

    element.reload();
    assert.isTrue(cancelStub.called);
  });

  test('reload() loads files weblinks', () => {
    element.change = createChange();
    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
        .returns({name: 'stubb', url: '#s'});
    stubRestApi('getDiff').returns(Promise.resolve({
      content: [],
    }));
    element.projectName = 'test-project';
    element.path = 'test-path';
    element.commitRange = {baseCommit: 'test-base', commit: 'test-commit'};
    element.patchRange = {};
    return element.reload().then(() => {
      assert.isTrue(weblinksStub.calledTwice);
      assert.isTrue(weblinksStub.firstCall.calledWith({
        commit: 'test-base',
        file: 'test-path',
        options: {
          weblinks: undefined,
        },
        repo: 'test-project',
        type: GerritNav.WeblinkType.FILE}));
      assert.isTrue(weblinksStub.secondCall.calledWith({
        commit: 'test-commit',
        file: 'test-path',
        options: {
          weblinks: undefined,
        },
        repo: 'test-project',
        type: GerritNav.WeblinkType.FILE}));
      assert.deepEqual(element.filesWeblinks, {
        meta_a: [{name: 'stubb', url: '#s'}],
        meta_b: [{name: 'stubb', url: '#s'}],
      });
    });
  });

  test('prefetch getDiff', done => {
    const diffRestApiStub = stubRestApi('getDiff')
        .returns(Promise.resolve({content: []}));
    element.changeNum = 123;
    element.patchRange = {basePatchNum: 1, patchNum: 2};
    element.path = 'file.txt';
    element.prefetchDiff();
    element._getDiff().then(() =>{
      assert.isTrue(diffRestApiStub.calledOnce);
      done();
    });
  });

  test('_getDiff handles null diff responses', done => {
    stubRestApi('getDiff').returns(Promise.resolve(null));
    element.changeNum = 123;
    element.patchRange = {basePatchNum: 1, patchNum: 2};
    element.path = 'file.txt';
    element._getDiff().then(done);
  });

  test('reload resolves on error', () => {
    const onErrStub = sinon.stub(element, '_handleGetDiffError');
    const error = new Response(null, {ok: false, status: 500});
    stubRestApi('getDiff').callsFake(
        (changeNum, basePatchNum, patchNum, path, whitespace, onErr) => {
          onErr(error);
        });
    element.patchRange = {};
    return element.reload().then(() => {
      assert.isTrue(onErrStub.calledOnce);
    });
  });

  suite('_handleGetDiffError', () => {
    let serverErrorStub;
    let pageErrorStub;

    setup(() => {
      serverErrorStub = sinon.stub();
      addListenerForTest(document, 'server-error', serverErrorStub);
      pageErrorStub = sinon.stub();
      addListenerForTest(document, 'page-error', pageErrorStub);
    });

    test('page error on HTTP-409', () => {
      element._handleGetDiffError({status: 409});
      assert.isTrue(serverErrorStub.calledOnce);
      assert.isFalse(pageErrorStub.called);
      assert.isNotOk(element._errorMessage);
    });

    test('server error on non-HTTP-409', () => {
      element._handleGetDiffError({
        status: 500,
        text: () => Promise.resolve(''),
      });
      assert.isFalse(serverErrorStub.called);
      assert.isTrue(pageErrorStub.calledOnce);
      assert.isNotOk(element._errorMessage);
    });

    test('error message if showLoadFailure', () => {
      element.showLoadFailure = true;
      element._handleGetDiffError({status: 500, statusText: 'Failure!'});
      assert.isFalse(serverErrorStub.called);
      assert.isFalse(pageErrorStub.called);
      assert.equal(element._errorMessage,
          'Encountered error when loading the diff: 500 Failure!');
    });
  });

  suite('image diffs', () => {
    let mockFile1;
    let mockFile2;
    setup(() => {
      mockFile1 = {
        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
        'wsAAAAAAAAAAAAAAAAA/w==',
        type: 'image/bmp',
      };
      mockFile2 = {
        body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
        'wsAAAAAAAAAAAAA/////w==',
        type: 'image/bmp',
      };

      element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
      element.change = createChange();
      element.comments = {
        left: [],
        right: [],
        meta: {patchRange: element.patchRange},
      };
    });

    test('renders image diffs with same file name', done => {
      const mockDiff = {
        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
          lines: 560},
        intraline_status: 'OK',
        change_type: 'MODIFIED',
        diff_header: [
          'diff --git a/carrot.jpg b/carrot.jpg',
          'index 2adc47d..f9c2f2c 100644',
          '--- a/carrot.jpg',
          '+++ b/carrot.jpg',
          'Binary files differ',
        ],
        content: [{skip: 66}],
        binary: true,
      };
      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
      stubRestApi('getImagesForDiff').returns(Promise.resolve({
        baseImage: {
          ...mockFile1,
          _expectedType: 'image/jpeg',
          _name: 'carrot.jpg',
        },
        revisionImage: {
          ...mockFile2,
          _expectedType: 'image/jpeg',
          _name: 'carrot.jpg',
        },
      }));

      const rendered = () => {
        // Recognizes that it should be an image diff.
        assert.isTrue(element.isImageDiff);
        assert.instanceOf(
            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);

        // Left image rendered with the parent commit's version of the file.
        const leftImage =
            element.$.diff.$.diffTable.querySelector('td.left img');
        const leftLabel =
            element.$.diff.$.diffTable.querySelector('td.left label');
        const leftLabelContent = leftLabel.querySelector('.label');
        const leftLabelName = leftLabel.querySelector('.name');

        const rightImage =
            element.$.diff.$.diffTable.querySelector('td.right img');
        const rightLabel = element.$.diff.$.diffTable.querySelector(
            'td.right label');
        const rightLabelContent = rightLabel.querySelector('.label');
        const rightLabelName = rightLabel.querySelector('.name');

        assert.isNotOk(rightLabelName);
        assert.isNotOk(leftLabelName);

        let leftLoaded = false;
        let rightLoaded = false;

        leftImage.addEventListener('load', () => {
          assert.isOk(leftImage);
          assert.equal(leftImage.getAttribute('src'),
              'data:image/bmp;base64,' + mockFile1.body);
          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
          leftLoaded = true;
          if (rightLoaded) {
            element.removeEventListener('render', rendered);
            done();
          }
        });

        rightImage.addEventListener('load', () => {
          assert.isOk(rightImage);
          assert.equal(rightImage.getAttribute('src'),
              'data:image/bmp;base64,' + mockFile2.body);
          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');

          rightLoaded = true;
          if (leftLoaded) {
            element.removeEventListener('render', rendered);
            done();
          }
        });
      };

      element.addEventListener('render', rendered);
      element.prefs = createDefaultDiffPrefs();
      element.reload();
    });

    test('renders image diffs with a different file name', done => {
      const mockDiff = {
        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
        meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
          lines: 560},
        intraline_status: 'OK',
        change_type: 'MODIFIED',
        diff_header: [
          'diff --git a/carrot.jpg b/carrot2.jpg',
          'index 2adc47d..f9c2f2c 100644',
          '--- a/carrot.jpg',
          '+++ b/carrot2.jpg',
          'Binary files differ',
        ],
        content: [{skip: 66}],
        binary: true,
      };
      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
      stubRestApi('getImagesForDiff').returns(Promise.resolve({
        baseImage: {
          ...mockFile1,
          _expectedType: 'image/jpeg',
          _name: 'carrot.jpg',
        },
        revisionImage: {
          ...mockFile2,
          _expectedType: 'image/jpeg',
          _name: 'carrot2.jpg',
        },
      }));

      const rendered = () => {
        // Recognizes that it should be an image diff.
        assert.isTrue(element.isImageDiff);
        assert.instanceOf(
            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);

        // Left image rendered with the parent commit's version of the file.
        const leftImage =
            element.$.diff.$.diffTable.querySelector('td.left img');
        const leftLabel =
            element.$.diff.$.diffTable.querySelector('td.left label');
        const leftLabelContent = leftLabel.querySelector('.label');
        const leftLabelName = leftLabel.querySelector('.name');

        const rightImage =
            element.$.diff.$.diffTable.querySelector('td.right img');
        const rightLabel = element.$.diff.$.diffTable.querySelector(
            'td.right label');
        const rightLabelContent = rightLabel.querySelector('.label');
        const rightLabelName = rightLabel.querySelector('.name');

        assert.isOk(rightLabelName);
        assert.isOk(leftLabelName);
        assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
        assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);

        let leftLoaded = false;
        let rightLoaded = false;

        leftImage.addEventListener('load', () => {
          assert.isOk(leftImage);
          assert.equal(leftImage.getAttribute('src'),
              'data:image/bmp;base64,' + mockFile1.body);
          assert.equal(leftLabelContent.textContent, '1×1 image/bmp');
          leftLoaded = true;
          if (rightLoaded) {
            element.removeEventListener('render', rendered);
            done();
          }
        });

        rightImage.addEventListener('load', () => {
          assert.isOk(rightImage);
          assert.equal(rightImage.getAttribute('src'),
              'data:image/bmp;base64,' + mockFile2.body);
          assert.equal(rightLabelContent.textContent, '1×1 image/bmp');

          rightLoaded = true;
          if (leftLoaded) {
            element.removeEventListener('render', rendered);
            done();
          }
        });
      };

      element.addEventListener('render', rendered);
      element.prefs = createDefaultDiffPrefs();
      element.reload();
    });

    test('renders added image', done => {
      const mockDiff = {
        meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
          lines: 560},
        intraline_status: 'OK',
        change_type: 'ADDED',
        diff_header: [
          'diff --git a/carrot.jpg b/carrot.jpg',
          'index 0000000..f9c2f2c 100644',
          '--- /dev/null',
          '+++ b/carrot.jpg',
          'Binary files differ',
        ],
        content: [{skip: 66}],
        binary: true,
      };
      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
      stubRestApi('getImagesForDiff').returns(Promise.resolve({
        baseImage: null,
        revisionImage: {
          ...mockFile2,
          _expectedType: 'image/jpeg',
          _name: 'carrot2.jpg',
        },
      }));

      element.addEventListener('render', () => {
        // Recognizes that it should be an image diff.
        assert.isTrue(element.isImageDiff);
        assert.instanceOf(
            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);

        const leftImage =
            element.$.diff.$.diffTable.querySelector('td.left img');
        const rightImage =
            element.$.diff.$.diffTable.querySelector('td.right img');

        assert.isNotOk(leftImage);
        assert.isOk(rightImage);
        done();
      });

      element.prefs = createDefaultDiffPrefs();
      element.reload();
    });

    test('renders removed image', done => {
      const mockDiff = {
        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
          lines: 560},
        intraline_status: 'OK',
        change_type: 'DELETED',
        diff_header: [
          'diff --git a/carrot.jpg b/carrot.jpg',
          'index f9c2f2c..0000000 100644',
          '--- a/carrot.jpg',
          '+++ /dev/null',
          'Binary files differ',
        ],
        content: [{skip: 66}],
        binary: true,
      };
      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
      stubRestApi('getImagesForDiff').returns(Promise.resolve({
        baseImage: {
          ...mockFile1,
          _expectedType: 'image/jpeg',
          _name: 'carrot.jpg',
        },
        revisionImage: null,
      }));

      element.addEventListener('render', () => {
        // Recognizes that it should be an image diff.
        assert.isTrue(element.isImageDiff);
        assert.instanceOf(
            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);

        const leftImage =
            element.$.diff.$.diffTable.querySelector('td.left img');
        const rightImage =
            element.$.diff.$.diffTable.querySelector('td.right img');

        assert.isOk(leftImage);
        assert.isNotOk(rightImage);
        done();
      });

      element.prefs = createDefaultDiffPrefs();
      element.reload();
    });

    test('does not render disallowed image type', done => {
      const mockDiff = {
        meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
          lines: 560},
        intraline_status: 'OK',
        change_type: 'DELETED',
        diff_header: [
          'diff --git a/carrot.jpg b/carrot.jpg',
          'index f9c2f2c..0000000 100644',
          '--- a/carrot.jpg',
          '+++ /dev/null',
          'Binary files differ',
        ],
        content: [{skip: 66}],
        binary: true,
      };
      mockFile1.type = 'image/jpeg-evil';

      stubRestApi('getDiff').returns(Promise.resolve(mockDiff));
      stubRestApi('getImagesForDiff').returns(Promise.resolve({
        baseImage: {
          ...mockFile1,
          _expectedType: 'image/jpeg',
          _name: 'carrot.jpg',
        },
        revisionImage: null,
      }));

      element.addEventListener('render', () => {
        // Recognizes that it should be an image diff.
        assert.isTrue(element.isImageDiff);
        assert.instanceOf(
            element.$.diff.$.diffBuilder._builder, GrDiffBuilderImage);
        const leftImage =
            element.$.diff.$.diffTable.querySelector('td.left img');
        assert.isNotOk(leftImage);
        done();
      });

      element.prefs = createDefaultDiffPrefs();
      element.reload();
    });
  });

  test('cannot create comments when not logged in', () => {
    element.patchRange = {
      basePatchNum: 'PARENT',
      patchNum: 2,
    };
    const showAuthRequireSpy = sinon.spy();
    element.addEventListener('show-auth-required', showAuthRequireSpy);

    element.dispatchEvent(new CustomEvent('create-comment', {
      detail: {
        lineNum: 3,
        side: Side.LEFT,
        path: '/p',
      },
    }));

    const threads = dom(element.$.diff)
        .queryDistributedElements('gr-comment-thread');

    assert.equal(threads.length, 0);

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

  test('delegates cancel()', () => {
    const stub = sinon.stub(element.$.diff, 'cancel');
    element.patchRange = {};
    element.cancel();
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  test('delegates getCursorStops()', () => {
    const returnValue = [document.createElement('b')];
    const stub = sinon.stub(element.$.diff, 'getCursorStops')
        .returns(returnValue);
    assert.equal(element.getCursorStops(), returnValue);
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  test('delegates isRangeSelected()', () => {
    const returnValue = true;
    const stub = sinon.stub(element.$.diff, 'isRangeSelected')
        .returns(returnValue);
    assert.equal(element.isRangeSelected(), returnValue);
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  test('delegates toggleLeftDiff()', () => {
    const stub = sinon.stub(element.$.diff, 'toggleLeftDiff');
    element.toggleLeftDiff();
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  suite('blame', () => {
    setup(async () => {
      element = basicFixture.instantiate();
      element.changeNum = 123;
      element.path = 'some/path';
      await flush();
    });

    test('clearBlame', () => {
      element._blame = [];
      const setBlameSpy = sinon.spy(element.$.diff.$.diffBuilder, 'setBlame');
      element.clearBlame();
      assert.isNull(element._blame);
      assert.isTrue(setBlameSpy.calledWithExactly(null));
      assert.equal(element.isBlameLoaded, false);
    });

    test('loadBlame', () => {
      const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
      const showAlertStub = sinon.stub();
      element.addEventListener('show-alert', showAlertStub);
      const getBlameStub = stubRestApi('getBlame')
          .returns(Promise.resolve(mockBlame));
      element.changeNum = 42;
      element.patchRange = {patchNum: 5, basePatchNum: 4};
      element.path = 'foo/bar.baz';
      return element.loadBlame().then(() => {
        assert.isTrue(getBlameStub.calledWithExactly(
            42, 5, 'foo/bar.baz', true));
        assert.isFalse(showAlertStub.called);
        assert.equal(element._blame, mockBlame);
        assert.equal(element.isBlameLoaded, true);
      });
    });

    test('loadBlame empty', () => {
      const mockBlame = [];
      const showAlertStub = sinon.stub();
      element.addEventListener('show-alert', showAlertStub);
      stubRestApi('getBlame')
          .returns(Promise.resolve(mockBlame));
      element.changeNum = 42;
      element.patchRange = {patchNum: 5, basePatchNum: 4};
      element.path = 'foo/bar.baz';
      return element.loadBlame()
          .then(() => {
            assert.isTrue(false, 'Promise should not resolve');
          })
          .catch(() => {
            assert.isTrue(showAlertStub.calledOnce);
            assert.isNull(element._blame);
            assert.equal(element.isBlameLoaded, false);
          });
    });
  });

  test('getThreadEls() returns .comment-threads', () => {
    const threadEl = document.createElement('div');
    threadEl.className = 'comment-thread';
    element.$.diff.appendChild(threadEl);
    assert.deepEqual(element.getThreadEls(), [threadEl]);
  });

  test('delegates addDraftAtLine(el)', () => {
    const param0 = document.createElement('b');
    const stub = sinon.stub(element.$.diff, 'addDraftAtLine');
    element.addDraftAtLine(param0);
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 1);
    assert.equal(stub.lastCall.args[0], param0);
  });

  test('delegates clearDiffContent()', () => {
    const stub = sinon.stub(element.$.diff, 'clearDiffContent');
    element.clearDiffContent();
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  test('delegates toggleAllContext()', () => {
    const stub = sinon.stub(element.$.diff, 'toggleAllContext');
    element.toggleAllContext();
    assert.isTrue(stub.calledOnce);
    assert.equal(stub.lastCall.args.length, 0);
  });

  test('passes in changeNum', () => {
    element.changeNum = 12345;
    assert.equal(element.$.diff.changeNum, 12345);
  });

  test('passes in noAutoRender', () => {
    const value = true;
    element.noAutoRender = value;
    assert.equal(element.$.diff.noAutoRender, value);
  });

  test('passes in path', () => {
    const value = 'some/file/path';
    element.path = value;
    assert.equal(element.$.diff.path, value);
  });

  test('passes in prefs', () => {
    const value = {};
    element.prefs = value;
    assert.equal(element.$.diff.prefs, value);
  });

  test('passes in changeNum', () => {
    element.changeNum = 12345;
    assert.equal(element.$.diff.changeNum, 12345);
  });

  test('passes in displayLine', () => {
    const value = true;
    element.displayLine = value;
    assert.equal(element.$.diff.displayLine, value);
  });

  test('passes in hidden', () => {
    const value = true;
    element.hidden = value;
    assert.equal(element.$.diff.hidden, value);
    assert.isNotNull(element.getAttribute('hidden'));
  });

  test('passes in noRenderOnPrefsChange', () => {
    const value = true;
    element.noRenderOnPrefsChange = value;
    assert.equal(element.$.diff.noRenderOnPrefsChange, value);
  });

  test('passes in lineWrapping', () => {
    const value = true;
    element.lineWrapping = value;
    assert.equal(element.$.diff.lineWrapping, value);
  });

  test('passes in viewMode', () => {
    const value = 'SIDE_BY_SIDE';
    element.viewMode = value;
    assert.equal(element.$.diff.viewMode, value);
  });

  test('passes in lineOfInterest', () => {
    const value = {number: 123, leftSide: true};
    element.lineOfInterest = value;
    assert.equal(element.$.diff.lineOfInterest, value);
  });

  suite('_reportDiff', () => {
    let reportStub;

    setup(async () => {
      element = basicFixture.instantiate();
      element.changeNum = 123;
      element.path = 'file.txt';
      element.patchRange = {basePatchNum: 1};
      reportStub = sinon.stub(element.reporting, 'reportInteraction');
      await flush();
    });

    test('null and content-less', () => {
      element._reportDiff(null);
      assert.isFalse(reportStub.called);

      element._reportDiff({});
      assert.isFalse(reportStub.called);
    });

    test('diff w/ no delta', () => {
      const diff = {
        content: [
          {ab: ['foo', 'bar']},
          {ab: ['baz', 'foo']},
        ],
      };
      element._reportDiff(diff);
      assert.isTrue(reportStub.calledOnce);
      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
      assert.isUndefined(reportStub.lastCall.args[1]);
    });

    test('diff w/ no rebase delta', () => {
      const diff = {
        content: [
          {ab: ['foo', 'bar']},
          {a: ['baz', 'foo']},
          {ab: ['foo', 'bar']},
          {a: ['baz', 'foo'], b: ['bar', 'baz']},
          {ab: ['foo', 'bar']},
          {b: ['baz', 'foo']},
          {ab: ['foo', 'bar']},
        ],
      };
      element._reportDiff(diff);
      assert.isTrue(reportStub.calledOnce);
      assert.equal(reportStub.lastCall.args[0], 'rebase-percent-zero');
      assert.isUndefined(reportStub.lastCall.args[1]);
    });

    test('diff w/ some rebase delta', () => {
      const diff = {
        content: [
          {ab: ['foo', 'bar']},
          {a: ['baz', 'foo'], due_to_rebase: true},
          {ab: ['foo', 'bar']},
          {a: ['baz', 'foo'], b: ['bar', 'baz']},
          {ab: ['foo', 'bar']},
          {b: ['baz', 'foo'], due_to_rebase: true},
          {ab: ['foo', 'bar']},
          {a: ['baz', 'foo']},
        ],
      };
      element._reportDiff(diff);
      assert.isTrue(reportStub.calledOnce);
      assert.isTrue(reportStub.calledWith(
          'rebase-percent-nonzero',
          {percentRebaseDelta: 50}
      ));
    });

    test('diff w/ all rebase delta', () => {
      const diff = {content: [{
        a: ['foo', 'bar'],
        b: ['baz', 'foo'],
        due_to_rebase: true,
      }]};
      element._reportDiff(diff);
      assert.isTrue(reportStub.calledOnce);
      assert.isTrue(reportStub.calledWith(
          'rebase-percent-nonzero',
          {percentRebaseDelta: 100}
      ));
    });

    test('diff against parent event', () => {
      element.patchRange.basePatchNum = 'PARENT';
      const diff = {content: [{
        a: ['foo', 'bar'],
        b: ['baz', 'foo'],
      }]};
      element._reportDiff(diff);
      assert.isTrue(reportStub.calledOnce);
      assert.equal(reportStub.lastCall.args[0], 'diff-against-parent');
      assert.isUndefined(reportStub.lastCall.args[1]);
    });
  });

  suite('create-comment', () => {
    setup(async () => {
      loggedIn = true;
      element.connectedCallback();
      await flush();
    });

    test('creates comments if they do not exist yet', () => {
      const diffSide = Side.LEFT;
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: 2,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          lineNum: 3,
          side: diffSide,
          path: '/p',
        },
      }));

      let threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');

      assert.equal(threads.length, 1);
      assert.equal(threads[0].diffSide, diffSide);
      assert.isTrue(threads[0].isOnParent);
      assert.equal(threads[0].range, undefined);
      assert.equal(threads[0].patchNum, 2);

      // Try to fetch a thread with a different range.
      const range = {
        start_line: 1,
        start_character: 1,
        end_line: 1,
        end_character: 3,
      };
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: 3,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          lineNum: 1,
          side: diffSide,
          path: '/p',
          range,
        },
      }));

      threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');

      assert.equal(threads.length, 2);
      assert.equal(threads[1].diffSide, diffSide);
      assert.isTrue(threads[0].isOnParent);
      assert.equal(threads[1].range, range);
      assert.equal(threads[1].patchNum, 3);
    });

    test('should not be on parent if on the right', () => {
      element.patchRange = {
        basePatchNum: 2,
        patchNum: 3,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: Side.RIGHT,
        },
      }));

      const thread = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread')[0];

      assert.isFalse(thread.isOnParent);
    });

    test('should be on parent if right and base is PARENT', () => {
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: 3,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: Side.LEFT,
        },
      }));

      const thread = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread')[0];

      assert.isTrue(thread.isOnParent);
    });

    test('should be on parent if right and base negative', () => {
      element.patchRange = {
        basePatchNum: -2, // merge parents have negative numbers
        patchNum: 3,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: Side.LEFT,
        },
      }));

      const thread = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread')[0];

      assert.isTrue(thread.isOnParent);
    });

    test('should not be on parent otherwise', () => {
      element.patchRange = {
        basePatchNum: 2, // merge parents have negative numbers
        patchNum: 3,
      };

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: Side.LEFT,
        },
      }));

      const thread = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread')[0];

      assert.isFalse(thread.isOnParent);
    });

    test('thread should use old file path if first created ' +
    'on patch set (left) before renaming', () => {
      const diffSide = Side.LEFT;
      element.patchRange = {
        basePatchNum: 2,
        patchNum: 3,
      };
      element.file = {basePath: 'file_renamed.txt', path: element.path};

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: diffSide,
          path: '/p',
        },
      }));

      const threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');

      assert.equal(threads.length, 1);
      assert.equal(threads[0].diffSide, diffSide);
      assert.equal(threads[0].path, element.file.basePath);
    });

    test('thread should use new file path if first created' +
    'on patch set (right) after renaming', () => {
      const diffSide = Side.RIGHT;
      element.patchRange = {
        basePatchNum: 2,
        patchNum: 3,
      };
      element.file = {basePath: 'file_renamed.txt', path: element.path};

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: diffSide,
          path: '/p',
        },
      }));

      const threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');

      assert.equal(threads.length, 1);
      assert.equal(threads[0].diffSide, diffSide);
      assert.equal(threads[0].path, element.file.path);
    });

    test('thread should use new file path if first created' +
    'on patch set (left) but is base', () => {
      const diffSide = Side.LEFT;
      element.patchRange = {
        basePatchNum: 'PARENT',
        patchNum: 3,
      };
      element.file = {basePath: 'file_renamed.txt', path: element.path};

      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: diffSide,
          path: '/p',
        },
      }));

      const threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');

      assert.equal(threads.length, 1);
      assert.equal(threads[0].diffSide, diffSide);
      assert.equal(threads[0].path, element.file.path);
    });

    test('cannot create thread on an edit', () => {
      const alertSpy = sinon.spy();
      element.addEventListener('show-alert', alertSpy);

      const diffSide = Side.LEFT;
      element.patchRange = {
        basePatchNum: EditPatchSetNum,
        patchNum: 3,
      };
      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: diffSide,
          path: '/p',
        },
      }));

      const threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');
      assert.equal(threads.length, 0);
      assert.isTrue(alertSpy.called);
    });

    test('cannot create thread on an edit base', () => {
      const alertSpy = sinon.spy();
      element.addEventListener('show-alert', alertSpy);

      const diffSide = Side.LEFT;
      element.patchRange = {
        basePatchNum: ParentPatchSetNum,
        patchNum: EditPatchSetNum,
      };
      element.dispatchEvent(new CustomEvent('create-comment', {
        detail: {
          side: diffSide,
          path: '/p',
        },
      }));

      const threads = dom(element.$.diff)
          .queryDistributedElements('gr-comment-thread');
      assert.equal(threads.length, 0);
      assert.isTrue(alertSpy.called);
    });
  });

  test('_filterThreadElsForLocation with no threads', () => {
    const line = {beforeNumber: 3, afterNumber: 5};

    const threads = [];
    assert.deepEqual(element._filterThreadElsForLocation(threads, line), []);
    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
        Side.LEFT), []);
    assert.deepEqual(element._filterThreadElsForLocation(threads, line,
        Side.RIGHT), []);
  });

  test('_filterThreadElsForLocation for line comments', () => {
    const line = {beforeNumber: 3, afterNumber: 5};

    const l3 = document.createElement('div');
    l3.setAttribute('line-num', 3);
    l3.setAttribute('diff-side', Side.LEFT);

    const l5 = document.createElement('div');
    l5.setAttribute('line-num', 5);
    l5.setAttribute('diff-side', Side.LEFT);

    const r3 = document.createElement('div');
    r3.setAttribute('line-num', 3);
    r3.setAttribute('diff-side', Side.RIGHT);

    const r5 = document.createElement('div');
    r5.setAttribute('line-num', 5);
    r5.setAttribute('diff-side', Side.RIGHT);

    const threadEls = [l3, l5, r3, r5];
    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
        Side.LEFT), [l3]);
    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
        Side.RIGHT), [r5]);
  });

  test('_filterThreadElsForLocation for file comments', () => {
    const line = {beforeNumber: 'FILE', afterNumber: 'FILE'};

    const l = document.createElement('div');
    l.setAttribute('diff-side', Side.LEFT);
    l.setAttribute('line-num', 'FILE');

    const r = document.createElement('div');
    r.setAttribute('diff-side', Side.RIGHT);
    r.setAttribute('line-num', 'FILE');

    const threadEls = [l, r];
    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
        Side.LEFT), [l]);
    assert.deepEqual(element._filterThreadElsForLocation(threadEls, line,
        Side.RIGHT), [r]);
  });

  suite('syntax layer with syntax_highlighting on', () => {
    setup(() => {
      const prefs = {
        line_length: 10,
        show_tabs: true,
        tab_size: 4,
        context: -1,
        syntax_highlighting: true,
      };
      element.patchRange = {};
      element.prefs = prefs;
      element.changeNum = 123;
      element.change = createChange();
      element.path = 'some/path';
    });

    test('gr-diff-host provides syntax highlighting layer', async () => {
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      await element.reload();
      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
    });

    test('rendering normal-sized diff does not disable syntax', () => {
      element.diff = {
        content: [{
          a: ['foo'],
        }],
      };
      assert.isTrue(element.syntaxLayer.enabled);
    });

    test('rendering large diff disables syntax', () => {
      // Before it renders, set the first diff line to 500 '*' characters.
      element.diff = {
        content: [{
          a: [new Array(501).join('*')],
        }],
      };
      assert.isFalse(element.syntaxLayer.enabled);
    });

    test('starts syntax layer processing on render event', async () => {
      sinon.stub(element.syntaxLayer, 'process')
          .returns(Promise.resolve());
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      await element.reload();
      element.dispatchEvent(
          new CustomEvent('render', {bubbles: true, composed: true}));
      assert.isTrue(element.syntaxLayer.process.called);
    });
  });

  suite('syntax layer with syntax_highlighting off', () => {
    setup(() => {
      const prefs = {
        line_length: 10,
        show_tabs: true,
        tab_size: 4,
        context: -1,
      };
      element.diff = {
        content: [{
          a: ['foo'],
        }],
      };
      element.patchRange = {};
      element.change = createChange();
      element.prefs = prefs;
    });

    test('gr-diff-host provides syntax highlighting layer', async () => {
      stubRestApi('getDiff').returns(Promise.resolve({content: []}));
      await element.reload();
      assert.equal(element.$.diff.layers[0], element.syntaxLayer);
    });

    test('syntax layer should be disabled', () => {
      assert.isFalse(element.syntaxLayer.enabled);
    });

    test('still disabled for large diff', () => {
      // Before it renders, set the first diff line to 500 '*' characters.
      element.diff = {
        content: [{
          a: [new Array(501).join('*')],
        }],
      };
      assert.isFalse(element.syntaxLayer.enabled);
    });
  });

  suite('coverage layer', () => {
    let notifyStub;
    let coverageProviderStub;
    const exampleRanges = [
      {
        type: CoverageType.COVERED,
        side: Side.RIGHT,
        code_range: {
          start_line: 1,
          end_line: 2,
        },
      },
      {
        type: CoverageType.NOT_COVERED,
        side: Side.RIGHT,
        code_range: {
          start_line: 3,
          end_line: 4,
        },
      },
    ];

    setup(async () => {
      notifyStub = sinon.stub();
      coverageProviderStub = sinon.stub().returns(
          Promise.resolve(exampleRanges));

      element = basicFixture.instantiate();
      sinon.stub(element.jsAPI, 'getCoverageAnnotationApis').returns(
          Promise.resolve([{
            notify: notifyStub,
            getCoverageProvider() {
              return coverageProviderStub;
            },
          }]));
      element.changeNum = 123;
      element.change = createChange();
      element.path = 'some/path';
      const prefs = {
        line_length: 10,
        show_tabs: true,
        tab_size: 4,
        context: -1,
      };
      element.diff = {
        content: [{
          a: ['foo'],
        }],
      };
      element.patchRange = {};
      element.prefs = prefs;
      stubRestApi('getDiff').returns(Promise.resolve(element.diff));
      await flush();
    });

    test('getCoverageAnnotationApis should be called', async () => {
      await element.reload();
      assert.isTrue(element.jsAPI.getCoverageAnnotationApis.calledOnce);
    });

    test('coverageRangeChanged should be called', async () => {
      await element.reload();
      assert.equal(notifyStub.callCount, 2);
      assert.isTrue(notifyStub.calledWithExactly(
          'some/path', 1, 2, Side.RIGHT));
      assert.isTrue(notifyStub.calledWithExactly(
          'some/path', 3, 4, Side.RIGHT));
    });

    test('provider is called with appropriate params', async () => {
      element.patchRange.basePatchNum = 1;
      element.patchRange.patchNum = 3;

      await element.reload();
      assert.isTrue(coverageProviderStub.calledWithExactly(
          123, 'some/path', 1, 3, element.change));
    });

    test('provider is called with appropriate params - special patchset values',
        async () => {
          element.patchRange.basePatchNum = 'PARENT';
          element.patchRange.patchNum = 'invalid';

          await element.reload();
          assert.isTrue(coverageProviderStub.calledWithExactly(
              123, 'some/path', undefined, undefined, element.change));
        });
  });

  suite('trailing newlines', () => {
    setup(() => {
    });

    suite('_lastChunkForSide', () => {
      test('deltas', () => {
        const diff = {content: [
          {a: ['foo', 'bar'], b: ['baz']},
          {ab: ['foo', 'bar', 'baz']},
          {b: ['foo']},
        ]};
        assert.equal(element._lastChunkForSide(diff, false), diff.content[2]);
        assert.equal(element._lastChunkForSide(diff, true), diff.content[1]);

        diff.content.push({a: ['foo'], b: ['bar']});
        assert.equal(element._lastChunkForSide(diff, false), diff.content[3]);
        assert.equal(element._lastChunkForSide(diff, true), diff.content[3]);
      });

      test('addition with a undefined', () => {
        const diff = {content: [
          {b: ['foo', 'bar', 'baz']},
        ]};
        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
        assert.isNull(element._lastChunkForSide(diff, true));
      });

      test('addition with a empty', () => {
        const diff = {content: [
          {a: [], b: ['foo', 'bar', 'baz']},
        ]};
        assert.equal(element._lastChunkForSide(diff, false), diff.content[0]);
        assert.isNull(element._lastChunkForSide(diff, true));
      });

      test('deletion with b undefined', () => {
        const diff = {content: [
          {a: ['foo', 'bar', 'baz']},
        ]};
        assert.isNull(element._lastChunkForSide(diff, false));
        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
      });

      test('deletion with b empty', () => {
        const diff = {content: [
          {a: ['foo', 'bar', 'baz'], b: []},
        ]};
        assert.isNull(element._lastChunkForSide(diff, false));
        assert.equal(element._lastChunkForSide(diff, true), diff.content[0]);
      });

      test('empty', () => {
        const diff = {content: []};
        assert.isNull(element._lastChunkForSide(diff, false));
        assert.isNull(element._lastChunkForSide(diff, true));
      });
    });

    suite('_hasTrailingNewlines', () => {
      test('shared no trailing', () => {
        const diff = undefined;
        sinon.stub(element, '_lastChunkForSide')
            .returns({ab: ['foo', 'bar']});
        assert.isFalse(element._hasTrailingNewlines(diff, false));
        assert.isFalse(element._hasTrailingNewlines(diff, true));
      });

      test('delta trailing in right', () => {
        const diff = undefined;
        sinon.stub(element, '_lastChunkForSide')
            .returns({a: ['foo', 'bar'], b: ['baz', '']});
        assert.isTrue(element._hasTrailingNewlines(diff, false));
        assert.isFalse(element._hasTrailingNewlines(diff, true));
      });

      test('addition', () => {
        const diff = undefined;
        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
          if (leftSide) { return null; }
          return {b: ['foo', '']};
        });
        assert.isTrue(element._hasTrailingNewlines(diff, false));
        assert.isNull(element._hasTrailingNewlines(diff, true));
      });

      test('deletion', () => {
        const diff = undefined;
        sinon.stub(element, '_lastChunkForSide').callsFake((diff, leftSide) => {
          if (!leftSide) { return null; }
          return {a: ['foo']};
        });
        assert.isNull(element._hasTrailingNewlines(diff, false));
        assert.isFalse(element._hasTrailingNewlines(diff, true));
      });
    });
  });
});

