/* @license
 * Copyright 2019 Google LLC. All Rights Reserved.
 * 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 {IS_IE11} from '../../constants.js';
import {$controls, $promptAnimatedContainer, $promptElement, CameraChangeDetails, cameraOrbitIntrinsics, ControlsInterface, ControlsMixin, INTERACTION_PROMPT, SphericalPosition} from '../../features/controls.js';
import ModelViewerElementBase, {$canvas, $scene} from '../../model-viewer-base.js';
import {StyleEvaluator} from '../../styles/evaluators.js';
import {ChangeSource, SmoothControls} from '../../three-components/SmoothControls.js';
import {Constructor} from '../../utilities.js';
import {assetPath, dispatchSyntheticEvent, rafPasses, timePasses, until, waitForEvent} from '../helpers.js';
import {BasicSpecTemplate} from '../templates.js';
import {settleControls} from '../three-components/SmoothControls-spec.js';

const expect = chai.expect;
const DEFAULT_FOV = 45;
const ASTRONAUT_GLB_PATH = assetPath('Astronaut.glb');

const interactWith = (element: HTMLElement) => {
  dispatchSyntheticEvent(element, 'mousedown', {clientX: 0, clientY: 10});
  dispatchSyntheticEvent(element, 'mousemove', {clientX: 0, clientY: 0});
};

const expectSphericalsToBeEqual =
    (sphericalOne: SphericalPosition, sphericalTwo: SphericalPosition) => {
      const precision = 5;

      expect(sphericalOne.theta.toFixed(precision))
          .to.be.equal(
              sphericalTwo.theta.toFixed(precision),
              'Spherical theta does not match');

      expect(sphericalOne.phi.toFixed(precision))
          .to.be.equal(
              sphericalTwo.phi.toFixed(precision),
              'Spherical phi does not match');

      expect(sphericalOne.radius.toFixed(precision))
          .to.be.equal(
              sphericalTwo.radius.toFixed(precision),
              'Spherical radius does not match');
    };

suite('ModelViewerElementBase with ControlsMixin', () => {
  suite('when registered', () => {
    let nextId = 0;
    let tagName: string;
    let ModelViewerElement:
        Constructor<ModelViewerElementBase&ControlsInterface>;

    setup(() => {
      tagName = `model-viewer-controls-${nextId++}`;
      ModelViewerElement = class extends ControlsMixin
      (ModelViewerElementBase) {
        static get is() {
          return tagName;
        }
      };
      customElements.define(tagName, ModelViewerElement);
    });

    BasicSpecTemplate(() => ModelViewerElement, () => tagName);

    suite('camera-orbit', () => {
      let element: ModelViewerElementBase&ControlsInterface;
      let controls: SmoothControls;
      let defaultRadius: number;

      setup(async () => {
        element = new ModelViewerElement();
        controls = (element as any)[$controls];
        document.body.appendChild(element);
        element.src = assetPath('cube.gltf');

        await waitForEvent(element, 'load');
        // NOTE(cdata): Sometimes the load event dispatches quickly enough to
        // cause a race condition where property change occurs _after_ load.
        // In this condition, it is possible for the controls to be "unsettled"
        // by the time that the test begins. Awaiting for a microtask ensures
        // that we always have time for one internal property change in the
        // element:
        await timePasses();

        settleControls(controls);

        const orbitIntrinsics = cameraOrbitIntrinsics(element);
        const evaluator = new StyleEvaluator([], orbitIntrinsics);

        defaultRadius = evaluator.evaluate()[2];
      });

      teardown(() => {
        if (element.parentNode != null) {
          element.parentNode.removeChild(element);
        }
      });

      test('defaults radius to ideal camera distance', () => {
        expect(element.getCameraOrbit().radius).to.be.equal(defaultRadius);
      });

      // TODO(#583)
      test.skip('can independently adjust azimuth', async () => {
        const orbit = element.getCameraOrbit();
        const nextTheta = orbit.theta + 1.0;

        element.cameraOrbit =
            `${nextTheta}rad ${orbit.phi}rad ${orbit.radius}m`;

        await timePasses();

        settleControls(controls);

        expectSphericalsToBeEqual(
            element.getCameraOrbit(), {...orbit, theta: nextTheta});
      });

      test('can independently adjust inclination', async () => {
        const orbit = element.getCameraOrbit();
        const nextPhi = orbit.phi + 1.0;

        element.cameraOrbit =
            `${orbit.theta}rad ${nextPhi}rad ${orbit.radius}m`;

        await timePasses();

        settleControls(controls);

        expectSphericalsToBeEqual(
            element.getCameraOrbit(), {...orbit, phi: nextPhi});
      });

      test('can independently adjust radius', async () => {
        const orbit = element.getCameraOrbit();
        const nextRadius = orbit.radius - 1.0;

        element.cameraOrbit =
            `${orbit.theta}rad ${orbit.phi}rad ${nextRadius}m`;

        await timePasses();

        settleControls(controls);

        expectSphericalsToBeEqual(
            element.getCameraOrbit(), {...orbit, radius: nextRadius});

        element.cameraOrbit = `${orbit.theta}rad ${orbit.phi}rad auto`;

        await timePasses();
        settleControls(controls);

        expect(element.getCameraOrbit().radius).to.be.equal(defaultRadius);
      });

      test('can independently adjust target', async () => {
        const target = element.getCameraTarget();
        target.x += 1;
        target.z += 1;

        element.cameraTarget = `${target.x}m auto ${target.z}m`;

        await timePasses();

        settleControls(controls);

        expect(element.getCameraTarget()).to.be.eql(target);
      });

      test('defaults FOV correctly', async () => {
        expect(element.getFieldOfView()).to.be.closeTo(DEFAULT_FOV, 0.00001);
      });

      test('can independently adjust FOV', async () => {
        const fov = element.getFieldOfView();
        const nextFov = fov - 1.0;

        element.fieldOfView = `${nextFov}deg`;

        await timePasses();

        settleControls(controls);

        expect(element.getFieldOfView()).to.be.closeTo(nextFov, 0.00001);
      });

      test('changes FOV basis when aspect ratio changes', async () => {
        const fov = element.getFieldOfView();
        element.setAttribute('style', 'width: 200px; height: 300px');

        await until(() => element.getFieldOfView() !== fov);
        expect(element.getFieldOfView()).to.be.greaterThan(fov);
      });

      test('causes camera-change event to fire', async () => {
        const cameraChangeDispatches = waitForEvent(element, 'camera-change');
        const cameraOrbit = element.getCameraOrbit();
        element.cameraOrbit = `${cameraOrbit.theta + 1}rad ${
            cameraOrbit.phi}rad ${cameraOrbit.radius}m`;

        await cameraChangeDispatches;
      });

      test('sets an appropriate event source', async () => {
        const cameraChangeDispatches =
            waitForEvent<CustomEvent<CameraChangeDetails>>(
                element, 'camera-change');

        const cameraOrbit = element.getCameraOrbit();
        element.cameraOrbit = `${cameraOrbit.theta + 1}rad ${
            cameraOrbit.phi}rad ${cameraOrbit.radius}m`;

        const event = await cameraChangeDispatches;
        expect(event.detail.source).to.be.equal(ChangeSource.NONE);
      });


      suite('getCameraOrbit', () => {
        setup(async () => {
          element.cameraOrbit = `1rad 1rad 1.5m`;
          await timePasses();
          settleControls(controls);
        });

        // NOTE(cdata): Flakey test is flakey
        test.skip('starts at the initially configured orbit', () => {
          const orbit = element.getCameraOrbit();

          expect(`${orbit.theta}rad ${orbit.phi}rad ${orbit.radius}m`)
              .to.be.equal(element.cameraOrbit);
        });

        test('updates with current orbit after interaction', async () => {
          controls.adjustOrbit(0, 0.5, 0, 0);
          settleControls(controls);

          const orbit = element.getCameraOrbit();
          expect(`${orbit.theta}rad ${orbit.phi}rad ${orbit.radius}m`)
              .to.equal(`1rad 0.5rad 1.5m`);
        });

        test('jumpCameraToGoal updates instantly', async () => {
          const cameraOrbit = `0.5rad 1.5rad 1.2m`;
          element.cameraOrbit = cameraOrbit;
          const fieldOfView = 30;
          element.fieldOfView = `${fieldOfView}deg`;
          element.jumpCameraToGoal();

          await rafPasses();

          expect(element.getFieldOfView()).to.be.closeTo(fieldOfView, 0.00001);
          let orbit = element.getCameraOrbit();
          // round to nearest 0.0001
          orbit.theta = Math.round(orbit.theta * 10000) / 10000;
          expect(`${orbit.theta}rad ${orbit.phi}rad ${orbit.radius}m`)
              .to.equal(cameraOrbit);
        });
      });
    });

    suite('camera-controls', () => {
      let element: ModelViewerElementBase&ControlsInterface;
      let controls: SmoothControls;

      setup(async () => {
        element = new ModelViewerElement();
        controls = (element as any)[$controls]
        document.body.appendChild(element);
        element.src = assetPath('cube.gltf');
        element.cameraControls = true;

        element.interactionPromptThreshold =
            100;  // Fairly low, to keep the test time down

        await waitForEvent(element, 'load');
      });

      teardown(() => {
        element.cameraControls = false;
        if (element.parentNode != null) {
          element.parentNode.removeChild(element);
        }
      });

      test('creates SmoothControls if enabled', () => {
        expect(controls).to.be.ok;
      });

      test(
          'requires focus to interact if policy is set to allow-when-focused',
          async () => {
            element.interactionPolicy = 'allow-when-focused';
            await timePasses();
            expect(controls.options.interactionPolicy)
                .to.be.equal('allow-when-focused');
          });

      test(
          'does not require focus to interact if policy is set to always-allow',
          async () => {
            element.interactionPolicy = 'always-allow';
            await timePasses();
            expect(controls.options.interactionPolicy)
                .to.be.equal('always-allow');
          });

      test('sets max radius greater than the camera framed distance', () => {
        const cameraDistance = element[$scene].camera.position.distanceTo(
            element[$scene].model.position);
        expect(controls.options.maximumRadius)
            .to.be.greaterThan(cameraDistance);
      });

      test('disables interaction if disabled after enabled', async () => {
        element.cameraControls = false;
        await timePasses();
        expect(controls.interactionEnabled).to.be.false;
      });

      suite('when user is interacting', () => {
        test('sets an appropriate camera-change event source', async () => {
          await rafPasses();
          element[$canvas].focus();
          interactWith(element[$canvas]);

          const cameraChangeDispatches =
              waitForEvent<CustomEvent<CameraChangeDetails>>(
                  element, 'camera-change');
          const event = await cameraChangeDispatches;

          expect(event.detail.source)
              .to.be.equal(ChangeSource.USER_INTERACTION);
        });
      });

      suite('interaction-prompt', () => {
        test('can be configured to never appear', async () => {
          element.interactionPrompt = 'none';
          await timePasses(element.interactionPromptThreshold + 100);

          const promptElement: HTMLElement = (element as any)[$promptElement];
          expect(promptElement.classList.contains('visible'))
              .to.be.equal(false);
        });

        test('can be configured to raise automatically', async () => {
          element.interactionPrompt = 'auto';
          await timePasses(element.interactionPromptThreshold + 100);

          const promptElement: HTMLElement = (element as any)[$promptElement];
          expect(promptElement.classList.contains('visible')).to.be.equal(true);
        });

        test('does not appear when camera-controls is disabled', async () => {
          element.interactionPrompt = 'auto';
          element.cameraControls = false;
          await timePasses(element.interactionPromptThreshold + 100);

          const promptElement: HTMLElement = (element as any)[$promptElement];
          expect(promptElement.classList.contains('visible'))
              .to.be.equal(false);
        });

        suite('when configured to be basic', () => {
          setup(async () => {
            element.interactionPromptStyle = 'basic';
            await timePasses();
          });

          test('does not have a css animation', () => {
            const computedStyle =
                getComputedStyle((element as any)[$promptAnimatedContainer]);
            expect(computedStyle.animationName).to.be.equal('none');
          });
        });
      });

      suite('a11y', () => {
        setup(async () => {
          element.alt = 'A 3D model of a cube';
          element.cameraOrbit = '0 90deg auto';
          await rafPasses();
        });

        test(
            'has initial aria-label set to alt before interaction',
            async () => {
              const canvas: HTMLCanvasElement = element[$scene].canvas;

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal(element.alt);
            });

        suite('when configured for focus-based interaction prompting', () => {
          setup(() => {
            element.interactionPrompt = 'when-focused';
          });

          test('prompts user to interact when focused', async () => {
            const canvas: HTMLCanvasElement = element[$scene].canvas;
            const promptElement: HTMLElement = (element as any)[$promptElement];

            settleControls(controls);

            // NOTE(cdata): This wait time was added in order to deflake tests
            // on iOS Simulator and Android Emulator on Sauce Labs. These same
            // test targets were tested manually locally and manually on Sauce,
            // and do not fail. Only automated Sauce tests seem to fail
            // consistently without this additional wait time:
            await rafPasses();

            canvas.focus();

            await until(
                () => canvas.getAttribute('aria-label') === INTERACTION_PROMPT);

            expect(promptElement.classList.contains('visible'))
                .to.be.equal(true);
          });

          test(
              'does not prompt users to interact before a model is loaded',
              async () => {
                if (IS_IE11) {
                  console.warn('Skipping this test for IE11 only');
                  return;
                }
                element.src = null;

                const canvas: HTMLCanvasElement = element[$scene].canvas;
                const promptElement: HTMLElement =
                    (element as any)[$promptElement];

                await rafPasses();

                canvas.focus();

                await timePasses(element.interactionPromptThreshold + 100);

                expect(promptElement.classList.contains('visible'))
                    .to.be.equal(false);

                canvas.blur();

                element.src = ASTRONAUT_GLB_PATH;
                await waitForEvent(element, 'load');

                canvas.focus();

                await until(() => promptElement.classList.contains('visible'));
              });

          // TODO(#584)
          test.skip('does not prompt if user already interacted', async () => {
            const canvas: HTMLCanvasElement = element[$scene].canvas;
            const promptElement = (element as any)[$promptElement];
            const originalLabel = canvas.getAttribute('aria-label');

            expect(originalLabel).to.not.be.equal(INTERACTION_PROMPT);

            canvas.focus();

            interactWith(canvas);

            await timePasses(element.interactionPromptThreshold + 100);

            expect(canvas.getAttribute('aria-label'))
                .to.not.be.equal(INTERACTION_PROMPT);
            expect(promptElement.classList.contains('visible'))
                .to.be.equal(false);
          });
        });

        test(
            'announces camera orientation when orbiting horizontally',
            async () => {
              const canvas: HTMLCanvasElement = element[$scene].canvas;

              await rafPasses();
              canvas.focus();

              controls.setOrbit(-Math.PI / 2.0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage left');

              controls.setOrbit(Math.PI / 2.0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage right');

              controls.adjustOrbit(-Math.PI / 2.0, 0, 0, 0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage back');

              controls.adjustOrbit(Math.PI, 0, 0, 0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage front');
            });

        test(
            'announces camera orientation when orbiting vertically',
            async () => {
              const canvas: HTMLCanvasElement = element[$scene].canvas;

              await rafPasses();
              canvas.focus();

              settleControls(controls);

              controls.setOrbit(0, 0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage upper-front');

              controls.adjustOrbit(0, -Math.PI / 2.0, 0, 0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage front');

              controls.adjustOrbit(0, -Math.PI / 2.0, 0, 0);
              settleControls(controls);

              expect(canvas.getAttribute('aria-label'))
                  .to.be.equal('View from stage lower-front');
            });
      });
    });
  });
});
