import SketchfabZipWorker from "../workers/sketchfab-zip.worker.js";
import { resolveMedia } from "../utils/media-utils";
import cubeMapPosX from "../assets/images/cubemap/posx.jpg";
import cubeMapNegX from "../assets/images/cubemap/negx.jpg";
import cubeMapPosY from "../assets/images/cubemap/posy.jpg";
import cubeMapNegY from "../assets/images/cubemap/negx.jpg";
import cubeMapPosZ from "../assets/images/cubemap/posz.jpg";
import cubeMapNegZ from "../assets/images/cubemap/negz.jpg";

const GLTFCache = {};
let CachedEnvMapTexture = null;

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;
}

/// Walks the tree of three.js objects starting at the given node, using the GLTF data
/// and template data to construct A-Frame entities and components when necessary.
/// (It's unnecessary to construct entities for subtrees that have no component data
/// or templates associated with any of their nodes.)
///
/// Returns the A-Frame entity associated with the given node, if one was constructed.
const inflateEntities = function(node, templates, gltfPath, isRoot) {
  // inflate subtrees first so that we can determine whether or not this node needs to be inflated
  const childEntities = [];
  const children = node.children.slice(0); // setObject3D mutates the node's parent, so we have to copy
  for (const child of children) {
    const el = inflateEntities(child, templates, gltfPath);
    if (el) {
      childEntities.push(el);
    }
  }

  const nodeHasBehavior = node.userData.components || node.name in templates;
  if (!nodeHasBehavior && !childEntities.length && !isRoot) {
    return null; // we don't need an entity for this node
  }

  const el = document.createElement("a-entity");
  el.append.apply(el, childEntities);

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

  // 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);
        }
      }
    }
  }

  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 getFilesFromSketchfabZip(src) {
  return new Promise((resolve, reject) => {
    const worker = new SketchfabZipWorker();
    worker.onmessage = e => {
      const [success, fileMapOrError] = e.data;
      (success ? resolve : reject)(fileMapOrError);
    };
    worker.postMessage(src);
  });
}

async function loadEnvMap() {
  const urls = [cubeMapPosX, cubeMapNegX, cubeMapPosY, cubeMapNegY, cubeMapPosZ, cubeMapNegZ];
  const texture = await new THREE.CubeTextureLoader().load(urls);
  texture.format = THREE.RGBFormat;
  return texture;
}

async function loadGLTF(src, preferredTechnique, onProgress) {
  const { raw, origin, contentType } = await resolveMedia(src);
  const basePath = THREE.LoaderUtils.extractUrlBase(origin);

  let gltfUrl = raw;
  let fileMap;

  if (contentType === "model/gltf+zip") {
    fileMap = await getFilesFromSketchfabZip(gltfUrl);
    gltfUrl = fileMap["scene.gtlf"];
  }

  const gltfLoader = new THREE.GLTFLoader();
  gltfLoader.setPath(basePath);
  gltfLoader.setLazy(true);

  const { parser } = await new Promise((resolve, reject) => gltfLoader.load(gltfUrl, resolve, onProgress, reject));

  const json = parser.json;
  const images = json.images;
  const buffers = json.buffers;
  const materials = json.materials;

  const pendingFarsparkPromises = [];

  if (images) {
    for (const image of images) {
      const imagePromise = resolveMedia(new URL(image.uri, parser.options.path).href).then(({ raw }) => {
        image.uri = raw;
      });

      pendingFarsparkPromises.push(imagePromise);
    }
  }

  if (buffers) {
    for (const buffer of buffers) {
      const bufferPromise = resolveMedia(new URL(buffer.uri, parser.options.path).href).then(({ raw }) => {
        buffer.uri = raw;
      });

      pendingFarsparkPromises.push(bufferPromise);
    }
  }

  if (materials) {
    for (let i = 0; i < materials.length; i++) {
      const material = materials[i];

      if (
        material.extensions &&
        material.extensions.MOZ_alt_materials &&
        material.extensions.MOZ_alt_materials[preferredTechnique] !== undefined
      ) {
        const altMaterialIndex = material.extensions.MOZ_alt_materials[preferredTechnique];
        materials[i] = altMaterialIndex;
      }
    }
  }

  if (!CachedEnvMapTexture) {
    CachedEnvMapTexture = loadEnvMap();
  }

  await Promise.all(pendingFarsparkPromises);

  const gltf = await new Promise((resolve, reject) =>
    parser.parse(
      (scene, scenes, cameras, animations, json) => {
        resolve({ scene, scenes, cameras, animations, json });
      },
      e => {
        reject(e);
      }
    )
  );

  const envMap = await CachedEnvMapTexture;

  gltf.scene.traverse(object => {
    if (object.material && object.material.type === "MeshStandardMaterial") {
      object.material.envMap = envMap;
      object.material.needsUpdate = true;
    }
  });

  if (fileMap) {
    // The GLTF is now cached as a THREE object, we can get rid of the original blobs
    Object.keys(fileMap).forEach(URL.revokeObjectURL);
  }

  return gltf;
}

/**
 * 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);

      if (!GLTFCache[src]) {
        GLTFCache[src] = loadGLTF(src, this.preferredTechnique);
      }

      const cachedModel = await GLTFCache[src];
      const model = cloneGltf(cachedModel);

      // 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;

      let object3DToSet = this.model;
      if (this.data.inflate && (this.inflatedEl = inflateEntities(this.model, this.templates, gltfPath, true))) {
        this.el.appendChild(this.inflatedEl);
        object3DToSet = this.inflatedEl.object3D;
        // 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.setObject3D("mesh", object3DToSet);
      this.el.emit("model-loaded", { format: "gltf", model: this.model });
    } catch (e) {
      delete GLTFCache[src];
      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;
    }
  }
});