const GLTFCache = {};

AFRAME.GLTFModelPlus = {
  // eslint-disable-next-line no-unused-vars
  defaultInflator(el, componentName, componentData, _gltfPath) {
    if (!AFRAME.components[componentName]) {
      throw new Error(`Inflator failed. "${componentName}" component does not exist.`);
    }
    if (AFRAME.components[componentName].multiple && Array.isArray(componentData)) {
      for (let i = 0; i < componentData.length; i++) {
        el.setAttribute(componentName + "__" + i, componentData[i]);
      }
    } else {
      el.setAttribute(componentName, componentData);
    }
  },
  registerComponent(componentKey, componentName, inflator) {
    AFRAME.GLTFModelPlus.components[componentKey] = {
      inflator: inflator || AFRAME.GLTFModelPlus.defaultInflator,
      componentName
    };
  },
  components: {}
};

// From https://gist.github.com/cdata/f2d7a6ccdec071839bc1954c32595e87
// Tracking glTF cloning here: https://github.com/mrdoob/three.js/issues/11573
function cloneGltf(gltf) {
  const skinnedMeshes = {};
  gltf.scene.traverse(node => {
    if (!node.name) {
      node.name = node.uuid;
    }
    if (node.isSkinnedMesh) {
      skinnedMeshes[node.name] = node;
    }
  });

  const clone = {
    animations: gltf.animations,
    scene: gltf.scene.clone(true)
  };

  const cloneBones = {};
  const cloneSkinnedMeshes = {};

  clone.scene.traverse(node => {
    if (node.isBone) {
      cloneBones[node.name] = node;
    }

    if (node.isSkinnedMesh) {
      cloneSkinnedMeshes[node.name] = node;
    }
  });

  for (const name in skinnedMeshes) {
    const skinnedMesh = skinnedMeshes[name];
    const skeleton = skinnedMesh.skeleton;
    const cloneSkinnedMesh = cloneSkinnedMeshes[name];

    const orderedCloneBones = [];

    for (let i = 0; i < skeleton.bones.length; ++i) {
      const cloneBone = cloneBones[skeleton.bones[i].name];
      orderedCloneBones.push(cloneBone);
    }

    cloneSkinnedMesh.bind(new THREE.Skeleton(orderedCloneBones, skeleton.boneInverses), cloneSkinnedMesh.matrixWorld);

    cloneSkinnedMesh.material = skinnedMesh.material.clone();
  }

  return clone;
}

const getBehaviorNodes = function(root, templates) {
  const nodes = new Set();
  if (root.userData.components || root.name in templates) {
    nodes.add(root);
  }
  for (const child of root.children) {
    for (const other of getBehaviorNodes(child, templates)) {
      nodes.add(other);
    }
  }
  return nodes;
};

const markLeaves = function(root, behaviorNodes) {
  root.userData.leaf = !behaviorNodes.has(root);
  for (const child of root.children) {
    if (!markLeaves(child, behaviorNodes)) {
      root.userData.leaf = false;
    }
  }
  return root.userData.leaf;
};

const inflateEntities = function(parentEl, node, gltfPath) {
  if (node.userData.leaf) {
    // we don't need an entity for this node
    return null;
  }

  // setObject3D mutates the node's parent, so we have to copy
  const children = node.children.slice(0);

  const el = document.createElement("a-entity");

  // Remove invalid CSS class name characters.
  const className = (node.name || node.uuid).replace(/[^\w-]/g, "");
  el.classList.add(className);
  parentEl.appendChild(el);

  // AFRAME rotation component expects rotations in YXZ, convert it
  if (node.rotation.order !== "YXZ") {
    node.rotation.setFromQuaternion(node.quaternion, "YXZ");
  }

  // Copy over the object's transform to the THREE.Group and reset the actual transform of the Object3D
  // all updates to the object should be done through the THREE.Group wrapper
  el.setAttribute("position", {
    x: node.position.x,
    y: node.position.y,
    z: node.position.z
  });
  el.setAttribute("rotation", {
    x: node.rotation.x * THREE.Math.RAD2DEG,
    y: node.rotation.y * THREE.Math.RAD2DEG,
    z: node.rotation.z * THREE.Math.RAD2DEG
  });
  el.setAttribute("scale", {
    x: node.scale.x,
    y: node.scale.y,
    z: node.scale.z
  });
  node.matrixAutoUpdate = false;
  node.matrix.identity();

  el.setObject3D(node.type.toLowerCase(), node);

  // Set the name of the `THREE.Group` to match the name of the node,
  // so that `THREE.PropertyBinding` will find (and later animate)
  // the group. See `PropertyBinding.findNode`:
  // https://github.com/mrdoob/three.js/blob/dev/src/animation/PropertyBinding.js#L211
  el.object3D.name = node.name;
  if (node.animations) {
    // Pass animations up to the group object so that when we can pass the group as
    // the optional root in `THREE.AnimationMixer.clipAction` and use the hierarchy
    // preserved under the group (but not the node). Otherwise `clipArray` will be
    // `null` in `THREE.AnimationClip.findByName`.
    node.parent.animations = node.animations;
  }

  const entityComponents = node.userData.components;
  if (entityComponents) {
    for (const prop in entityComponents) {
      if (entityComponents.hasOwnProperty(prop) && AFRAME.GLTFModelPlus.components.hasOwnProperty(prop)) {
        const { inflator, componentName } = AFRAME.GLTFModelPlus.components[prop];

        if (inflator) {
          inflator(el, componentName, entityComponents[prop], gltfPath);
        }
      }
    }
  }

  children.forEach(childNode => {
    inflateEntities(el, childNode, gltfPath);
  });

  return el;
};

function attachTemplate(root, name, templateRoot) {
  const targetEls = root.querySelectorAll("." + name);
  for (const el of targetEls) {
    const root = templateRoot.cloneNode(true);
    // Merge root element attributes with the target element
    for (const { name, value } of root.attributes) {
      el.setAttribute(name, value);
    }

    // Append all child elements
    while (root.children.length > 0) {
      el.appendChild(root.children[0]);
    }
  }
}

function nextTick() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

function cachedLoadGLTF(src, preferredTechnique, onProgress) {
  return new Promise((resolve, reject) => {
    // Load the gltf model from the cache if it exists.
    if (GLTFCache[src]) {
      // Use a cloned copy of the cached model.
      resolve(cloneGltf(GLTFCache[src]));
    } else {
      // Otherwise load the new gltf model.
      const gltfLoader = new THREE.GLTFLoader();
      gltfLoader.preferredTechnique = preferredTechnique;

      gltfLoader.load(
        src,
        model => {
          if (!GLTFCache[src]) {
            // Store a cloned copy of the gltf model.
            GLTFCache[src] = cloneGltf(model);
          }
          resolve(model);
        },
        onProgress,
        reject
      );
    }
  });
}

/**
 * Loads a GLTF model, optionally recursively "inflates" the child nodes of a model into a-entities and sets
 * whitelisted components on them if defined in the node's extras.
 * @namespace gltf
 * @component gltf-model-plus
 */
AFRAME.registerComponent("gltf-model-plus", {
  schema: {
    src: { type: "string" },
    inflate: { default: false }
  },

  init() {
    this.preferredTechnique = AFRAME.utils.device.isMobile() ? "KHR_materials_unlit" : "pbrMetallicRoughness";
    this.loadTemplates();
  },

  update() {
    this.applySrc(this.data.src);
  },

  loadTemplates() {
    this.templates = [];
    this.el.querySelectorAll(":scope > template").forEach(templateEl => {
      const root = document.importNode(templateEl.firstElementChild || templateEl.content.firstElementChild, true);
      this.templates[templateEl.getAttribute("data-name")] = root;
    });
  },

  async applySrc(src) {
    try {
      // If the src attribute is a selector, get the url from the asset item.
      if (src && src.charAt(0) === "#") {
        const assetEl = document.getElementById(src.substring(1));
        src = assetEl.getAttribute("src");
      }

      if (src === this.lastSrc) return;
      this.lastSrc = src;

      if (!src) {
        if (this.inflatedEl) {
          console.warn("gltf-model-plus set to an empty source, unloading inflated model.");
          this.removeInflatedEl();
        }
        return;
      }

      const gltfPath = THREE.LoaderUtils.extractUrlBase(src);
      const model = await cachedLoadGLTF(src, this.preferredTechnique);

      // If we started loading something else already
      // TODO: there should be a way to cancel loading instead
      if (src != this.lastSrc) return;

      // If we had inflated something already before, clean that up
      this.removeInflatedEl();

      this.model = model.scene || model.scenes[0];
      this.model.animations = model.animations;

      this.el.setObject3D("mesh", this.model);

      if (this.data.inflate) {
        const behaviorNodes = getBehaviorNodes(this.model, this.templates);
        markLeaves(this.model, behaviorNodes);
        this.inflatedEl = inflateEntities(this.el, this.model, gltfPath);
        // TODO: Still don't fully understand the lifecycle here and how it differs between browsers, we should dig in more
        // Wait one tick for the appended custom elements to be connected before attaching templates
        await nextTick();
        if (src != this.lastSrc) return; // TODO: there must be a nicer pattern for this
        for (const name in this.templates) {
          attachTemplate(this.el, name, this.templates[name]);
        }
      }

      this.el.emit("model-loaded", { format: "gltf", model: this.model });
    } catch (e) {
      console.error("Failed to load glTF model", e, this);
      this.el.emit("model-error", { format: "gltf", src });
    }
  },

  removeInflatedEl() {
    if (this.inflatedEl) {
      this.inflatedEl.parentNode.removeChild(this.inflatedEl);
      delete this.inflatedEl;
    }
  }
});