import { isEqual } from "lodash-es";
import {
  action,
  computed,
  IComputedValue,
  IObservableValue,
  IReactionDisposer,
  observable,
  reaction,
  runInAction,
  untracked,
  makeObservable
} from "mobx";
import { fromPromise, FULFILLED, IPromiseBasedObservable } from "mobx-utils";
import CesiumEvent from "terriajs-cesium/Source/Core/Event";
import Rectangle from "terriajs-cesium/Source/Core/Rectangle";
import CatalogMemberMixin from "../ModelMixins/CatalogMemberMixin";
import MappableMixin from "../ModelMixins/MappableMixin";
import CameraView from "../Models/CameraView";
import GlobeOrMap from "../Models/GlobeOrMap";
import NoViewer from "../Models/NoViewer";
import Terria from "../Models/Terria";
import ViewerMode, { getViewerType } from "../Models/ViewerMode";

// Async loading of Leaflet and Cesium

const leafletFromPromise = computed(
  () =>
    fromPromise(import("../Models/Leaflet").then((Leaflet) => Leaflet.default)),
  { keepAlive: true }
);

const cesiumFromPromise = computed(
  () =>
    fromPromise(import("../Models/Cesium").then((Cesium) => Cesium.default)),
  { keepAlive: true }
);

// Viewer options. Designed to be easily serialisable
interface ViewerOptions {
  useTerrain: boolean;
  [key: string]: string | number | boolean;
}

const viewerOptionsDefaults: ViewerOptions = {
  useTerrain: true
};
/**
 * A class that deals with initialising, destroying and switching between viewers
 * Each map-view should have it's own TerriaViewer (main viewer, preview map, etc.)
 */
export default class TerriaViewer {
  readonly terria: Terria;

  @observable
  private _baseMap: MappableMixin.Instance | undefined;

  /**
   * Tracks the basemap that is currently being loaded
   */
  @observable
  private _loadingBaseMap: MappableMixin.Instance | undefined;

  get baseMap() {
    return this._baseMap;
  }

  /**
   * Returns the basemap that is currently loading
   */
  get loadingBaseMap(): MappableMixin.Instance | undefined {
    return this._loadingBaseMap;
  }

  async setBaseMap(baseMap?: MappableMixin.Instance): Promise<void> {
    if (!baseMap) return;

    runInAction(() => {
      this._loadingBaseMap = baseMap;
    });

    try {
      const result = await baseMap.loadMapItems();
      if (result.error) {
        result.raiseError(this.terria, {
          title: {
            key: "models.terria.loadingBaseMapErrorTitle",
            parameters: {
              name:
                (CatalogMemberMixin.isMixedInto(baseMap)
                  ? baseMap.name
                  : baseMap.uniqueId) ?? "Unknown item"
            }
          }
        });
      } else {
        runInAction(() => {
          // Concurrent attempts to load basemap might not complete in the same
          // order they were called. Set as current basemap only if this was
          // the last call to setBaseMap.
          if (this._loadingBaseMap === baseMap) {
            // If the basemap specifies a preferred viewer mode, switch to it.
            if (baseMap.preferredViewerMode) {
              this.viewerMode =
                getViewerType(baseMap.preferredViewerMode) ?? this.viewerMode;
            }
            this._baseMap = baseMap;
          }
        });
      }
    } finally {
      // Unset loadingBaseMap
      if (this._loadingBaseMap === baseMap) {
        runInAction(() => {
          this._loadingBaseMap = undefined;
        });
      }
    }
  }

  /**
   * Switch to a base map that is compatible with the current viewer
   *
   * @returns A promise that yields `true` if the switch was made.
   */
  async useViewerCompatibleBaseMap(): Promise<boolean> {
    const currentViewerType = this.viewerMode;
    const baseMapViewerType = this.baseMap?.preferredViewerMode
      ? getViewerType(this.baseMap.preferredViewerMode)
      : undefined;

    if (!baseMapViewerType || baseMapViewerType === currentViewerType) {
      return false;
    }

    // Select a base map that either does not require a specific viewer mode or
    // specifies a compatible mode.
    const compatibleBaseMap = this.terria.baseMapsModel.baseMapItems.find(
      (it) =>
        !it.item.preferredViewerMode ||
        getViewerType(it.item.preferredViewerMode) === currentViewerType
    )?.item;

    return compatibleBaseMap
      ? this.setBaseMap(compatibleBaseMap).then(() => true)
      : false;
  }

  // This is a "view" of a workbench/other
  readonly items:
    | IComputedValue<MappableMixin.Instance[]>
    | IObservableValue<MappableMixin.Instance[]>;

  @observable
  viewerMode: ViewerMode | undefined = ViewerMode.Cesium;

  // Set by UI
  @observable
  viewerOptions: ViewerOptions = viewerOptionsDefaults;

  // Disable all mouse (& keyboard) interaction
  @observable
  disableInteraction: boolean = false;

  _homeCamera: CameraView = new CameraView(Rectangle.MAX_VALUE);

  @computed
  get homeCamera() {
    return this._homeCamera;
  }
  set homeCamera(cameraView: CameraView) {
    if (isEqual(this._homeCamera.rectangle, Rectangle.MAX_VALUE)) {
      this.currentViewer.zoomTo(cameraView, 0.0);
    }
    this._homeCamera = cameraView;
  }

  @observable
  mapContainer: string | HTMLElement | undefined;

  /**
   * The distance between two pixels at the bottom center of the screen.
   * Set in lib/ReactViews/Map/Legend/DistanceLegend.jsx
   */
  @observable scale: number = 1;

  readonly beforeViewerChanged = new CesiumEvent();
  readonly afterViewerChanged = new CesiumEvent();

  constructor(terria: Terria, items: IComputedValue<MappableMixin.Instance[]>) {
    makeObservable(this);
    this.terria = terria;
    this.items = items;

    if (!this.viewerChangeTracker) {
      this.viewerChangeTracker = reaction(
        () => this.currentViewer,
        () => {
          this.afterViewerChanged.raiseEvent();
        }
      );
    }
  }

  get attached(): boolean {
    return this.mapContainer !== undefined;
  }

  private _lastViewer: GlobeOrMap | undefined;

  viewerChangeTracker: IReactionDisposer | undefined = undefined;

  /**
   * Promise for async loading of current `viewerMode`
   * Starts when TerriaViewer is attached to a div and `viewerMode` is set
   */
  @computed
  get viewerLoadPromise(): Promise<void> {
    return Promise.resolve(this._currentViewerConstructorPromise).then(
      () => {}
    );
  }

  /**
   * Get a mobx-utils promise to a constructor for currentViewer. Start loading
   * Leaflet or Cesium depending on `viewerMode` if attached to a div
   */
  @computed
  get _currentViewerConstructorPromise() {
    let viewerFromPromise: IPromiseBasedObservable<
      new (
        terriaViewer: TerriaViewer,
        container: string | HTMLElement
      ) => GlobeOrMap
    > = fromPromise.resolve(NoViewer) as IPromiseBasedObservable<
      typeof NoViewer
    >;
    if (this.attached && this.viewerMode === ViewerMode.Leaflet) {
      viewerFromPromise = leafletFromPromise.get();
    } else if (this.attached && this.viewerMode === ViewerMode.Cesium) {
      viewerFromPromise = cesiumFromPromise.get();
    }
    return viewerFromPromise;
  }

  @computed({
    keepAlive: true
  })
  get currentViewer(): GlobeOrMap {
    // Use untracked on everything to ensure the viewer isn't recreated
    //  except when the viewer is required to change, the currently required
    //  viewer class finishes loading from an async chunk or the map container
    //  is changed

    const currentView = untracked(() => this.destroyCurrentViewer());

    let newViewer: GlobeOrMap;
    try {
      // If a div is attached and a viewer is ready, use it
      if (
        this.attached &&
        this._currentViewerConstructorPromise.state === FULFILLED
      ) {
        const SomeViewer = this._currentViewerConstructorPromise.value;
        newViewer = untracked(() => new SomeViewer(this, this.mapContainer!));
      } else {
        newViewer = untracked(() => new NoViewer(this));
      }
    } catch (error) {
      // Switch viewerMode inside computed. Could change viewers to
      //  guarantee no throw in constructor and instead have a `start()`
      //  method that can throw. Then call that `start()` method inside
      //  a reaction (reaction would also deal with viewer fallback).
      // Using this approach might remove the need for `untracked`
      setTimeout(
        action(() => {
          this.terria.raiseErrorToUser(error);
          this.viewerMode =
            this.viewerMode === ViewerMode.Cesium
              ? ViewerMode.Leaflet
              : undefined;
        }),
        0
      );
      newViewer = untracked(() => new NoViewer(this));
    }

    this._lastViewer = newViewer;
    newViewer.setInitialView(currentView || untracked(() => this.homeCamera));

    return newViewer;
  }

  // Pull out attaching logic into it's own step. This allows constructing a TerriaViewer
  // before its UI element is mounted in React to set basemap, items, viewermode
  @action
  attach(mapContainer?: string | HTMLElement): void {
    this.mapContainer = mapContainer;
  }

  @action
  detach(): void {
    // Detach from a container
    this.mapContainer = undefined;
    this.destroyCurrentViewer();
  }

  destroy(): void {
    this.detach();
    this.viewerChangeTracker?.();
  }

  private destroyCurrentViewer() {
    let currentView: CameraView | undefined;
    if (this._lastViewer !== undefined) {
      this.beforeViewerChanged.raiseEvent();
      currentView = this._lastViewer.getCurrentCameraView();
      this._lastViewer.destroy();
      this._lastViewer = undefined;
    }
    return currentView;
  }
}
