/* @license
 * Copyright 2020 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 {Group, Mesh, Object3D} from 'three';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {SkeletonUtils} from 'three/examples/jsm/utils/SkeletonUtils.js';
import {Constructor} from '../utilities.js';

export const $prepared = Symbol('prepared');

export interface PreparedGLTF extends GLTF {
  [$prepared]?: boolean;
}

export const $prepare = Symbol('prepare');
export const $preparedGLTF = Symbol('preparedGLTF');
export const $clone = Symbol('clone');

/**
 * Represents the preparation and enhancement of the output of a Three.js
 * GLTFLoader (a Three.js-flavor "GLTF"), to make it suitable for optimal,
 * correct viewing in a given presentation context and also make the cloning
 * process more explicit and legible.
 *
 * A GLTFInstance is API-compatible with a Three.js-flavor "GLTF", so it should
 * be considered to be interchangeable with the loaded result of a GLTFLoader.
 *
 * This basic implementation only implements trivial preparation and enhancement
 * of a GLTF. These operations are intended to be enhanced by inheriting
 * classes.
 */
export class GLTFInstance implements GLTF {
  /**
   * Prepares a given GLTF for presentation and future cloning. A GLTF that is
   * prepared can safely have this method invoked on it multiple times; it will
   * only be prepared once, including after being cloned.
   */
  static prepare(source: GLTF): PreparedGLTF {
    if (source.scene == null) {
      throw new Error('Model does not have a scene');
    }

    if ((source as PreparedGLTF)[$prepared]) {
      return source;
    }

    const prepared = this[$prepare](source) as Partial<PreparedGLTF>;

    // NOTE: ES5 Symbol polyfill is not compatible with spread operator
    // so {...prepared, [$prepared]: true} does not work
    prepared[$prepared] = true;

    return prepared as PreparedGLTF;
  }

  /**
   * Override in an inheriting class to apply specialty one-time preparations
   * for a given input GLTF.
   */
  protected static[$prepare](source: GLTF): GLTF {
    // TODO(#195,#1003): We don't currently support multiple scenes, so we don't
    // bother preparing extra scenes for now:
    const {scene} = source;
    const scenes = [scene];

    return {...source, scene, scenes};
  }

  protected[$preparedGLTF]: PreparedGLTF;

  get parser() {
    return this[$preparedGLTF].parser;
  }

  get animations() {
    return this[$preparedGLTF].animations;
  }

  get scene() {
    return this[$preparedGLTF].scene;
  }

  get scenes() {
    return this[$preparedGLTF].scenes;
  }

  get cameras() {
    return this[$preparedGLTF].cameras;
  }

  get asset() {
    return this[$preparedGLTF].asset;
  }

  get userData() {
    return this[$preparedGLTF].userData;
  }

  constructor(preparedGLTF: PreparedGLTF) {
    this[$preparedGLTF] = preparedGLTF;
  }

  /**
   * Creates and returns a copy of this instance.
   */
  clone<T extends GLTFInstance>(): T {
    const GLTFInstanceConstructor = this.constructor as Constructor<T>;
    const clonedGLTF = this[$clone]();

    return new GLTFInstanceConstructor(clonedGLTF);
  }

  /**
   * Cleans up any retained memory that might not otherwise be released when
   * this instance is done being used.
   */
  dispose(): void {
    this.scenes.forEach((scene: Group) => {
      scene.traverse((object: Object3D) => {
        if (!(object as Mesh).isMesh) {
          return;
        }
        const mesh = object as Mesh;
        const materials =
            Array.isArray(mesh.material) ? mesh.material : [mesh.material];
        materials.forEach(material => {
          material.dispose();
        });
        mesh.geometry.dispose();
      });
    });
  }

  /**
   * Override in an inheriting class to implement specialized cloning strategies
   */
  protected[$clone](): PreparedGLTF {
    const source = this[$preparedGLTF];
    // TODO(#195,#1003): We don't currently support multiple scenes, so we don't
    // bother cloning extra scenes for now:
    const scene = SkeletonUtils.clone(this.scene) as Group;
    const scenes = [scene];
    const userData = source.userData ? {...source.userData} : {};
    return {...source, scene, scenes, userData};
  }
}

export type GLTFInstanceConstructor =
    Constructor<GLTFInstance, {prepare: typeof GLTFInstance['prepare']}>;
