import "./css/Viewer.css";
import "photo-sphere-viewer/dist/photo-sphere-viewer.css";
import { Viewer as PSViewer } from "photo-sphere-viewer";
import { EquirectangularTilesAdapter } from "photo-sphere-viewer/dist/adapters/equirectangular-tiles";
import {
  VirtualTourPlugin,
  MODE_GPS as VTP_MODE_GPS,
  MODE_3D as VTP_MODE_3D,
  MODE_SERVER as VTP_MODE_SERVER,
} from "photo-sphere-viewer/dist/plugins/virtual-tour";
import "photo-sphere-viewer/dist/plugins/virtual-tour.css";
import API from "./API";
import "./PreviousButton";
import "./NextButton";
import "./PlayButton";
import Map from "./Map";
import MiniComponentButtons from "./MiniComponentButtons";
import LoaderImgBase from "./img/loader_base.jpg";
import LoaderImgTile0 from "./img/loader_0.jpg";
import LoaderImgTile1 from "./img/loader_1.jpg";

const BASE_PANORAMA = {
  baseUrl: LoaderImgBase,
  width: 1280,
  cols: 2,
  rows: 1,
  tileUrl: (col) => (col === 0 ? LoaderImgTile0 : LoaderImgTile1),
};

/**
 * Viewer is the component allowing display of 360° pictures.
 *
 * @augments {external:photo-sphere-viewer.Viewer}
 * @fires picture-loaded
 * @param {string|Node} container The DOM element to create viewer into
 * @param {string} endpoint URL to API to use (must be a [STAC API Item Search endpoint](https://github.com/radiantearth/stac-api-spec/tree/master/item-search))
 * @param {object} [options] Viewer options
 * @param {string} [options.picId] Initial picture identifier to display
 * @param {number[]} [options.position] Initial position to go to (in [lat, lon] format)
 * @param {boolean} [options.player=true] Enable sequence player buttons (next/prev/play buttons)
 * @param {boolean|object} [options.map=false] Enable contextual map for locating pictures. Setting to true or passing an object enables the map. Various settings can be passed, either the ones defined here, or any of [MapLibre GL settings](https://maplibre.org/maplibre-gl-js-docs/api/map/#map-parameters)
 * @param {string} [options.map.picturesTiles] URL for fetching pictures vector tiles (if map is enabled, defaults to GeoVisio /api/map endpoint)
 * @param {boolean} [options.map.startWide] Show the map as main element at startup (defaults to false, viewer is wider at start)
 * @param {number} [options.map.minZoom=0] The minimum zoom level of the map (0-24).
 * @param {number} [options.map.maxZoom=22] The maximum zoom level of the map (0-24).
 * @param {object|string} [options.map.style] The map's MapLibre style. This must be an a JSON object conforming to the schema described in the [MapLibre Style Specification](https://maplibre.org/maplibre-gl-js-docs/style-spec/), or a URL to such JSON.
 * @param {(boolean|string)} [options.map.hash=false] If `true`, the map's position (zoom, center latitude, center longitude, bearing, and pitch) will be synced with the hash fragment of the page's URL.
 *   For example, `http://path/to/my/page.html#2.59/39.26/53.07/-24.1/60`.
 *   An additional string may optionally be provided to indicate a parameter-styled hash, e.g. http://path/to/my/page.html#map=2.59/39.26/53.07/-24.1/60&foo=bar, where foo is a custom parameter and bar is an arbitrary hash distinct from the map hash.
 * @param {external:maplibre-gl.LngLatLike} [options.map.center=[0, 0]] The initial geographical centerpoint of the map. If `center` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `[0, 0]` Note: MapLibre GL uses longitude, latitude coordinate order (as opposed to latitude, longitude) to match GeoJSON.
 * @param {number} [options.map.zoom=0] The initial zoom level of the map. If `zoom` is not specified in the constructor options, MapLibre GL JS will look for it in the map's style object. If it is not specified in the style, either, it will default to `0`.
 * @param {external:maplibre-gl.LngLatBoundsLike} [options.map.bounds] The initial bounds of the map. If `bounds` is specified, it overrides `center` and `zoom` constructor options.
 */
class Viewer extends PSViewer {
  constructor(container, endpoint, options = {}) {
    super({
      container,
      adapter: [
        EquirectangularTilesAdapter,
        {
          showErrorTile: false,
        },
      ],
      caption: "GeoVisio",
      panorama: BASE_PANORAMA,
      minFov: 5,
      navbar: ["zoom"]
        .concat(
          options.player === undefined || options.player
            ? ["sequence-prev", "sequence-play", "sequence-next"]
            : []
        )
        .concat(["caption", "move"]),
      plugins: [
        [
          VirtualTourPlugin,
          {
            positionMode: VTP_MODE_GPS,
            renderMode: VTP_MODE_3D,
            dataMode: VTP_MODE_SERVER,
            preload: true,
            rotateSpeed: false,
            getNode: (picId) => {
              if (!picId) {
                return;
              }
              return fetch(this._myApi.getPictureMetadataUrl(picId))
                .then((res) => res.json())
                .then((metadata) => {
                  metadata = metadata.features.pop();
                  if (!metadata) {
                    throw new Error(
                      "Picture with ID " + picId + " was not found"
                    );
                  }

                  const horizontalFov =
                    metadata.properties["pers:interior_orientation"] &&
                    metadata.properties["pers:interior_orientation"][
                      "field_of_view"
                    ]
                      ? parseInt(
                          metadata.properties["pers:interior_orientation"][
                            "field_of_view"
                          ]
                        )
                      : 70;
                  const is360 = horizontalFov === 360;
                  const hdUrl = (
                    Object.values(metadata.assets).find((a) =>
                      a.roles.includes("data")
                    ) || {}
                  ).href;
                  const matrix = metadata.properties["tiles:tile_matrix_sets"]
                    ? metadata.properties["tiles:tile_matrix_sets"].geovisio
                    : null;
                  const prev = metadata.links.find(
                    (l) => l.rel === "prev" && l.type === "application/geo+json"
                  );
                  const next = metadata.links.find(
                    (l) => l.rel === "next" && l.type === "application/geo+json"
                  );
                  const baseUrlWebp = Object.values(metadata.assets).find(
                    (a) => a.roles.includes("visual") && a.type === "image/webp"
                  );
                  const baseUrlJpeg = Object.values(metadata.assets).find(
                    (a) => a.roles.includes("visual") && a.type === "image/jpeg"
                  );
                  const tileUrl =
                    metadata?.asset_templates?.tiles_webp ||
                    metadata?.asset_templates?.tiles;

                  const res = {
                    id: metadata.id,
                    links: metadata.links
                      .filter(
                        (l) =>
                          ["next", "prev"].includes(l.rel) &&
                          l.type === "application/geo+json"
                      )
                      .map((l) => ({
                        nodeId: l.id,
                        position: l.geometry.coordinates,
                      })),
                    panorama: is360
                      ? {
                          baseUrl: (baseUrlWebp || baseUrlJpeg).href,
                          cols: matrix && matrix.tileMatrix[0].matrixWidth,
                          rows: matrix && matrix.tileMatrix[0].matrixHeight,
                          width:
                            matrix &&
                            matrix.tileMatrix[0].matrixWidth *
                              matrix.tileMatrix[0].tileWidth,
                          tileUrl:
                            matrix &&
                            ((col, row) =>
                              tileUrl.href
                                .replace(/\{TileCol\}/g, col)
                                .replace(/\{TileRow\}/g, row)),
                        }
                      : {
                          // Flat pictures are shown only using a cropped base panorama
                          baseUrl: hdUrl,
                          basePanoData: (img) => {
                            const verticalFov =
                              (horizontalFov * img.height) / img.width;
                            const panoWidth = (img.width * 360) / horizontalFov;
                            const panoHeight = (img.height * 180) / verticalFov;

                            return {
                              fullWidth: panoWidth,
                              fullHeight: panoHeight,
                              croppedWidth: img.width,
                              croppedHeight: img.height,
                              croppedX: (panoWidth - img.width) / 2,
                              croppedY: (panoHeight - img.height) / 2,
                              poseHeading:
                                metadata.properties["view:azimuth"] || 0,
                            };
                          },
                          // This is only to mock loading of tiles (which are not available for flat pictures)
                          cols: 2,
                          rows: 1,
                          width: 2,
                          tileUrl: () => "",
                        },
                    position: metadata.geometry.coordinates,
                    sequence: {
                      id: metadata.collection,
                      nextPic: next ? next.id : undefined,
                      prevPic: prev ? prev.id : undefined,
                    },
                    sphereCorrection: metadata.properties["view:azimuth"]
                      ? {
                          pan:
                            -metadata.properties["view:azimuth"] *
                            (Math.PI / 180),
                        }
                      : { pan: 0 },
                    horizontalFov,
                  };
                  console.log(res);
                  return res;
                });
            },
          },
        ],
      ],
    });
    this._myApi = new API(endpoint, options?.map?.picturesTiles);
    this._myVTour = this.getPlugin(VirtualTourPlugin);
    this._sequencePlaying = false;
    this._prevSequence = null;

    // Call appropriate functions at start according to initial options
    if (typeof options === "object") {
      const onReady = () => {
        if (options.picId) {
          this.goToPicture(options.picId);
        }

        if (options.position) {
          this.goToPosition(...options.position).catch((e) => console.log(e));
        }
      };
      
      if (options.map) {
        this._map = new Map(this, options.map);
        this._map._map.once("load", onReady);
        this._mapWide = false;
        this.container.classList.add("psv--has-map");
        this._miniButtons = new MiniComponentButtons(this);
        if (typeof options.map === "object" && options.map.startWide) {
          this.setMapWide(true);
        }
      } else {
        onReady();
      }
    }

    // Custom events handlers
    this.on("position-updated", (e, position) => {
      /**
       * Event for viewer rotation
       *
       * @event view-rotated
       * @type {object}
       * @property {number} x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West)
       * @property {number} y New y position (in degrees)
       */
      this.trigger("view-rotated", this._positionToXY(position));

      this._onTilesStartLoading();
    });

    this._myVTour.on("node-changed", (e, nodeId) => {
      if (nodeId) {
        const picMeta = this.getPictureMetadata();
        // Set rotation angle + fov on initial load / sequence change
        const currentSequence = picMeta.sequence.id;
        if (
          !this._prevSequence ||
          currentSequence !== this._prevSequence ||
          picMeta.horizontalFov < 360
        ) {
          this.setOption(
            "maxFov",
            Math.min((picMeta.horizontalFov * 3) / 4, 90)
          );
          this.rotate({
            longitude: -picMeta.sphereCorrection.pan,
            latitude: 0,
          });
          this.zoom(50);
        }

        this._prevSequence = currentSequence;

        /**
         * Event for picture load (low-resolution image is loaded)
         *
         * @event picture-loaded
         * @type {object}
         * @property {string} picId The picture unique identifier
         * @property {number} lon Longitude (WGS84)
         * @property {number} lat Latitude (WGS84)
         * @property {number} x New x position (in degrees, 0-360), corresponds to heading (0° = North, 90° = East, 180° = South, 270° = West)
         * @property {number} y New y position (in degrees)
         */
        this.trigger("picture-loaded", {
          ...this._positionToXY(this.getPosition()),
          picId: nodeId,
          lon: picMeta.position[0],
          lat: picMeta.position[1],
        });
      }

      this._onTilesStartLoading();

      // Update prev/next picture in sequence buttons
      if (this.navbar.getButton("sequence-prev")) {
        if (
          this.getPictureMetadata() &&
          this.getPictureMetadata().sequence.prevPic
        ) {
          this.navbar.getButton("sequence-prev").enable();
        } else {
          this.navbar.getButton("sequence-prev").disable();
        }
      }

      if (this.navbar.getButton("sequence-next")) {
        if (
          this.getPictureMetadata() &&
          this.getPictureMetadata().sequence.nextPic
        ) {
          this.navbar.getButton("sequence-next").enable();
        } else {
          this.navbar.getButton("sequence-next").disable();
        }
      }
    });
  }

  /**
   * Converts result from getPosition or position-updated event into x/y coordinates
   *
   * @private
   * @param {object} pos latitude/longitude as given by PSV
   * @returns {object} Same coordinates as x/y and in degrees
   */
  _positionToXY(pos) {
    return {
      x: pos.longitude * (180 / Math.PI),
      y: pos.latitude * (180 / Math.PI),
    };
  }

  /**
   * Event handler for loading a new range of tiles
   *
   * @private
   */
  _onTilesStartLoading() {
    if (this._tilesQueueTimer) {
      clearInterval(this._tilesQueueTimer);
      delete this._tilesQueueTimer;
    }
    this._tilesQueueTimer = setInterval(() => {
      if (Object.keys(this.adapter.queue.tasks).length === 0) {
        if (this._myVTour.prop.currentNode) {
          /**
           * Event launched when all visible tiles of a picture are loaded
           *
           * @event picture-tiles-loaded
           * @type {object}
           * @property {string} picId The picture unique identifier
           */
          this.trigger("picture-tiles-loaded", {
            picId: this._myVTour.prop.currentNode.id,
          });
        }
        clearInterval(this._tilesQueueTimer);
        delete this._tilesQueueTimer;
      }
    }, 100);
  }

  /**
   * Click handler for next/prev navbar buttons
   *
   * @param {('next'|'prev')} type Set if it's next or previous button
   * @private
   */
  _onNextPrevPicClick(type) {
    if (
      this.getPictureMetadata() &&
      this.getPictureMetadata().sequence[type + "Pic"]
    ) {
      // Actually change current picture
      if (type === "prev") {
        this.goToPrevPicture();
      } else if (type === "next") {
        this.goToNextPicture();
      }
    }
  }

  /**
   * Get position of sphere currently shown to user
   *
   * @returns {object} Position in format { x: heading in degrees (0° = North, 90° = East, 180° = South, 270° = West), y: top/bottom position in degrees (-90° = bottom, 0° = front, 90° = top) }
   */
  getXY() {
    return this._positionToXY(this.getPosition());
  }

  /**
   * Access currently shown picture metadata
   *
   * @returns {object} Picture metadata
   */
  getPictureMetadata() {
    return this._myVTour.prop.currentNode
      ? Object.assign({}, this._myVTour.prop.currentNode)
      : null;
  }

  /**
   * Access the map object (if any)
   * Allows you to call any of [the MapLibre GL Map function](https://maplibre.org/maplibre-gl-js-docs/api/map/) for advanced map management
   *
   * @returns {null|external:maplibre-gl.Map} The map
   */
  getMap() {
    return this._map ? this._map._map : null;
  }

  /**
   * Displays in viewer specified picture
   *
   * @param {string} picId The picture unique identifier
   */
  goToPicture(picId) {
    // Actually move to wanted picture
    this._myVTour.setCurrentNode(picId);
  }

  /**
   * Goes continuously to next picture in sequence as long as possible
   */
  playSequence() {
    this._sequencePlaying = true;

    /**
     * Event for sequence starting to play
     *
     * @event sequence-playing
     * @type {object}
     */
    this.trigger("sequence-playing");

    const nextPicturePlay = () => {
      if (this._sequencePlaying) {
        this.once("picture-tiles-loaded", () => {
          this._playTimer = setTimeout(() => {
            nextPicturePlay();
          }, 1500);
        });

        try {
          this.goToNextPicture();
        } catch (e) {
          this.stopSequence();
        }
      }
    };

    // Stop playing if user clicks on image
    this.on("click", (e) => {
      console.log(e);
      this.stopSequence();
    });

    nextPicturePlay();
  }

  /**
   * Stops playing current sequence
   */
  stopSequence() {
    this._sequencePlaying = false;

    if (this._playTimer) {
      clearTimeout(this._playTimer);
      delete this._playTimer;
    }

    /**
     * Event for sequence stopped playing
     *
     * @event sequence-stopped
     * @type {object}
     */
    this.trigger("sequence-stopped");
  }

  /**
   * Is there any sequence being played right now ?
   *
   * @returns {boolean} True if sequence is playing
   */
  isSequencePlaying() {
    return this._sequencePlaying;
  }

  /**
   * Displays next picture in current sequence (if any)
   */
  goToNextPicture() {
    if (!this.getPictureMetadata()) {
      throw new Error("No picture currently selected");
    }

    const next = this.getPictureMetadata().sequence.nextPic;
    if (next) {
      this.goToPicture(next);
    } else {
      throw new Error("No next picture available");
    }
  }

  /**
   * Displays previous picture in current sequence (if any)
   */
  goToPrevPicture() {
    if (!this.getPictureMetadata()) {
      throw new Error("No picture currently selected");
    }

    const prev = this.getPictureMetadata().sequence.prevPic;
    if (prev) {
      this.goToPicture(prev);
    } else {
      throw new Error("No previous picture available");
    }
  }

  /**
   * Displays in viewer a picture near to given coordinates
   *
   * @param {number} lat Latitude (WGS84)
   * @param {number} lon Longitude (WGS84)
   * @returns {Promise} Resolves on picture ID if picture found, otherwise rejects
   */
  goToPosition(lat, lon) {
    return fetch(this._myApi.getPicturesAroundCoordinatesUrl(lat, lon))
      .then((res) => res.json())
      .then((res) => {
        if (res.features.length > 0) {
          this.goToPicture(res.features[0].id);
          return res.features[0].id;
        } else {
          return Promise.reject(
            new Error("No picture found nearby given coordinates")
          );
        }
      });
  }

  /**
   * Is the map shown as main element instead of viewer (wide map mode) ?
   *
   * @returns {boolean} True if map is wider than viewer
   */
  isMapWide() {
    if (!this._map) {
      throw new Error("Map is not enabled");
    }
    return this._mapWide;
  }

  /**
   * Change the map size (either small in a corner or wider than viewer)
   *
   * @param {boolean} wideMap True to make map wider than viewer
   */
  setMapWide(wideMap) {
    if (!this._map) {
      throw new Error("Map is not enabled");
    }

    // Change map size
    this._mapWide = wideMap;
    this._map.setWide(this._mapWide);

    // Change viewer size
    if (this._mapWide) {
      this.container.classList.add("gvs-small");
    } else {
      this.container.classList.remove("gvs-small");
    }
    this.autoSize();
  }

  /**
   * Reduce component visibility (shown as a badge button)
   *
   * @private
   */
  _minimize() {
    this.container.classList.add("gvs-minimized");
  }

  /**
   * Show component as a classic widget (invert operation of minimize)
   *
   * @private
   */
  _maximize() {
    this.container.classList.remove("gvs-minimized");
  }
}

export default Viewer;
