// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

// This file is derived from the Cesium code base under Apache 2 license
// See LICENSE.md and https://github.com/AnalyticalGraphicsInc/cesium/blob/master/LICENSE.md

import {Matrix4, Vector3} from '@math.gl/core';
import {Ellipsoid} from '@math.gl/geospatial';
import {Stats} from '@probe.gl/stats';
import {RequestScheduler, path, LoaderWithParser, LoaderOptions} from '@loaders.gl/loader-utils';
import {TilesetCache} from './tileset-cache';
import {calculateTransformProps} from './helpers/transform-utils';
import {FrameState, getFrameState, limitSelectedTiles} from './helpers/frame-state';
import {getZoomFromBoundingVolume, getZoomFromExtent, getZoomFromFullExtent} from './helpers/zoom';

import type {GeospatialViewport, Viewport} from '../types';
import {Tile3D} from './tile-3d';
import {TILESET_TYPE} from '../constants';

import {TilesetTraverser} from './tileset-traverser';

// TODO - these should be moved into their respective modules
import {Tileset3DTraverser} from './format-3d-tiles/tileset-3d-traverser';
import {I3STilesetTraverser} from './format-i3s/i3s-tileset-traverser';

export type TilesetJSON = any;

/*
export type TilesetJSON = {
    loader;
    // could be  3d tiles, i3s
    type: 'I3S' | '3DTILES';
    /** The url to the top level tileset JSON file. *
    url: string;
    basePath?: string;
    // Geometric error when the tree is not rendered at all
    lodMetricType: string;
    lodMetricValue: number;
    root: {
      refine: string;
      [key: string]: unknown;
    },
    [key: string]: unknown;
};
*/

export type Tileset3DProps = {
  // loading
  throttleRequests?: boolean;
  maxRequests?: number;
  loadOptions?: LoaderOptions;
  loadTiles?: boolean;
  basePath?: string;
  maximumMemoryUsage?: number;
  memoryCacheOverflow?: number;
  maximumTilesSelected?: number;
  debounceTime?: number;

  // Metadata
  description?: string;
  attributions?: string[];

  // Transforms
  ellipsoid?: object;
  modelMatrix?: Matrix4;

  // Traversal
  maximumScreenSpaceError?: number;
  memoryAdjustedScreenSpaceError?: boolean;
  viewportTraversersMap?: any;
  updateTransforms?: boolean;
  viewDistanceScale?: number;

  // Callbacks
  onTileLoad?: (tile: Tile3D) => any;
  onTileUnload?: (tile: Tile3D) => any;
  onTileError?: (tile: Tile3D, message: string, url: string) => any;
  contentLoader?: (tile: Tile3D) => Promise<void>;
  onTraversalComplete?: (selectedTiles: Tile3D[]) => Tile3D[];
};

type Props = {
  description: string;
  ellipsoid: object;
  /** A 4x4 transformation matrix this transforms the entire tileset. */
  modelMatrix: Matrix4;
  /** Set to false to disable network request throttling */
  throttleRequests: boolean;
  /** Number of simultaneous requsts, if throttleRequests is true */
  maxRequests: number;
  /* Maximum amount of GPU memory (in MB) that may be used to cache tiles. */
  maximumMemoryUsage: number;
  /* The maximum additional memory (in MB) to allow for cache headroom before adjusting the screen spacer error */
  memoryCacheOverflow: number;
  /** Maximum number limit of tiles selected for show. 0 means no limit */
  maximumTilesSelected: number;
  /** Delay time before the tileset traversal. It prevents traversal requests spam.*/
  debounceTime: number;
  /** Callback. Indicates this a tile's content was loaded */
  onTileLoad: (tile: Tile3D) => void;
  /** Callback. Indicates this a tile's content was unloaded (cache full) */
  onTileUnload: (tile: Tile3D) => void;
  /** Callback. Indicates this a tile's content failed to load */
  onTileError: (tile: Tile3D, message: string, url: string) => void;
  /** Callback. Allows post-process selectedTiles right after traversal. */
  onTraversalComplete: (selectedTiles: Tile3D[]) => Tile3D[];
  /** The maximum screen space error used to drive level of detail refinement. */
  maximumScreenSpaceError: number;
  /** Whether to adjust the maximum screen space error to comply with the maximum memory limitation */
  memoryAdjustedScreenSpaceError: boolean;
  viewportTraversersMap: Record<string, any> | null;
  attributions: string[];
  loadTiles: boolean;
  loadOptions: LoaderOptions;
  updateTransforms: boolean;
  /** View distance scale modifier */
  viewDistanceScale: number;
  basePath: string;
  /** Optional async tile content loader */
  contentLoader?: (tile: Tile3D) => Promise<void>;
  /** @todo I3S specific knowledge should be moved to I3S module */
  i3s: Record<string, any>;
};

const DEFAULT_PROPS: Props = {
  description: '',
  ellipsoid: Ellipsoid.WGS84,
  modelMatrix: new Matrix4(),
  throttleRequests: true,
  maxRequests: 64,
  /** Default memory values optimized for viewing mesh-based 3D Tiles on both mobile and desktop devices */
  maximumMemoryUsage: 32,
  memoryCacheOverflow: 1,
  maximumTilesSelected: 0,
  debounceTime: 0,
  onTileLoad: () => {},
  onTileUnload: () => {},
  onTileError: () => {},
  onTraversalComplete: (selectedTiles: Tile3D[]) => selectedTiles,
  contentLoader: undefined,
  viewDistanceScale: 1.0,
  maximumScreenSpaceError: 8,
  memoryAdjustedScreenSpaceError: false,
  loadTiles: true,
  updateTransforms: true,
  viewportTraversersMap: null,
  loadOptions: {fetch: {}},
  attributions: [],
  basePath: '',
  i3s: {}
};

// Tracked Stats
const TILES_TOTAL = 'Tiles In Tileset(s)';
const TILES_IN_MEMORY = 'Tiles In Memory';
const TILES_IN_VIEW = 'Tiles In View';
const TILES_RENDERABLE = 'Tiles To Render';
const TILES_LOADED = 'Tiles Loaded';
const TILES_LOADING = 'Tiles Loading';
const TILES_UNLOADED = 'Tiles Unloaded';
const TILES_LOAD_FAILED = 'Failed Tile Loads';
const POINTS_COUNT = 'Points/Vertices';
const TILES_GPU_MEMORY = 'Tile Memory Use';
const MAXIMUM_SSE = 'Maximum Screen Space Error';

/**
 * The Tileset loading and rendering flow is as below,
 * A rendered (i.e. deck.gl `Tile3DLayer`) triggers `tileset.update()` after a `tileset` is loaded
 * `tileset` starts traversing the tile tree and update `requestTiles` (tiles of which content need
 * to be fetched) and `selectedTiles` (tiles ready for rendering under the current viewport).
 * `Tile3DLayer` will update rendering based on `selectedTiles`.
 * `Tile3DLayer` also listens to `onTileLoad` callback and trigger another round of `update and then traversal`
 * when new tiles are loaded.

 * As I3S tileset have stored `tileHeader` file (metadata) and tile content files (geometry, texture, ...) separately.
 * During each traversal, it issues `tilHeader` requests if that `tileHeader` is not yet fetched,
 * after the tile header is fulfilled, it will resume the traversal starting from the tile just fetched (not root).

 * Tile3DLayer
 *      |
 *  await load(tileset)
 *      |
 *  tileset.update()
 *      |                async load tileHeader
 *  tileset.traverse() -------------------------- Queued
 *      |        resume traversal after fetched  |
 *      |----------------------------------------|
 *      |
 *      |                     async load tile content
 * tilset.requestedTiles  ----------------------------- RequestScheduler
 *                                                             |
 * tilset.selectedTiles (ready for rendering)                  |
 *      |         Listen to                                    |
 *   Tile3DLayer ----------- onTileLoad  ----------------------|
 *      |                         |   notify new tile is available
 *   updateLayers                 |
 *                       tileset.update // trigger another round of update
*/
export class Tileset3D {
  // props: Tileset3DProps;
  options: Props;
  loadOptions: LoaderOptions;

  type: TILESET_TYPE;
  tileset: TilesetJSON;
  loader: LoaderWithParser;
  url: string;
  basePath: string;
  modelMatrix: Matrix4;
  ellipsoid: any;
  lodMetricType: string;
  lodMetricValue: number;
  refine: string;
  root: Tile3D | null = null;
  roots: Record<string, Tile3D> = {};
  /** @todo any->unknown */
  asset: Record<string, any> = {};

  // Metadata for the entire tileset
  description: string = '';
  properties: any;

  extras: any = null;
  attributions: any = {};
  credits: any = {};

  stats: Stats;

  /** flags that contain information about data types in nested tiles */
  contentFormats = {draco: false, meshopt: false, dds: false, ktx2: false};

  // view props
  cartographicCenter: Vector3 | null = null;
  cartesianCenter: Vector3 | null = null;
  zoom: number = 1;
  boundingVolume: any = null;

  /** Updated based on the camera position and direction */
  dynamicScreenSpaceErrorComputedDensity: number = 0.0;

  // METRICS

  /**
   * The maximum amount of GPU memory (in MB) that may be used to cache tiles
   * Tiles not in view are unloaded to enforce private
   */
  maximumMemoryUsage: number = 32;

  /** The total amount of GPU memory in bytes used by the tileset. */
  gpuMemoryUsageInBytes: number = 0;

  /**
   * If loading the level of detail required by maximumScreenSpaceError
   * results in the memory usage exceeding maximumMemoryUsage (GPU), level of detail refinement
   * will instead use this (larger) adjusted screen space error to achieve the
   * best possible visual quality within the available memory.
   */
  memoryAdjustedScreenSpaceError: number = 0.0;

  private _cacheBytes: number = 0;
  private _cacheOverflowBytes: number = 0;

  /** Update tracker. increase in each update cycle. */
  _frameNumber: number = 0;
  private _queryParams: Record<string, string> = {};
  private _extensionsUsed: string[] = [];
  private _tiles: Record<string, Tile3D> = {};

  /** counter for tracking tiles requests */
  private _pendingCount: number = 0;

  /** Hold traversal results */
  selectedTiles: Tile3D[] = [];

  // TRAVERSAL
  traverseCounter: number = 0;
  geometricError: number = 0;
  private lastUpdatedVieports: Viewport[] | Viewport | null = null;
  private _requestedTiles: Tile3D[] = [];
  private _emptyTiles: Tile3D[] = [];
  private frameStateData: any = {};

  _traverser: TilesetTraverser;
  _cache = new TilesetCache();
  _requestScheduler: RequestScheduler;

  // Promise tracking
  private updatePromise: Promise<number> | null = null;
  tilesetInitializationPromise: Promise<void>;

  /**
   * Create a new Tileset3D
   * @param json
   * @param props
   */
  // eslint-disable-next-line max-statements
  constructor(tileset: TilesetJSON, options?: Tileset3DProps) {
    // PUBLIC MEMBERS
    this.options = {...DEFAULT_PROPS, ...options};
    // raw data
    this.tileset = tileset;
    this.loader = tileset.loader;
    // could be  3d tiles, i3s
    this.type = tileset.type;
    // The url to a tileset JSON file.
    this.url = tileset.url;
    this.basePath = tileset.basePath || path.dirname(this.url);
    this.modelMatrix = this.options.modelMatrix;
    this.ellipsoid = this.options.ellipsoid;

    // Geometric error when the tree is not rendered at all
    this.lodMetricType = tileset.lodMetricType;
    this.lodMetricValue = tileset.lodMetricValue;
    this.refine = tileset.root.refine;

    this.loadOptions = this.options.loadOptions || {};

    // TRAVERSAL
    this._traverser = this._initializeTraverser();
    this._requestScheduler = new RequestScheduler({
      throttleRequests: this.options.throttleRequests,
      maxRequests: this.options.maxRequests
    });

    this.memoryAdjustedScreenSpaceError = this.options.maximumScreenSpaceError;
    this._cacheBytes = this.options.maximumMemoryUsage * 1024 * 1024;
    this._cacheOverflowBytes = this.options.memoryCacheOverflow * 1024 * 1024;

    // METRICS
    // The total amount of GPU memory in bytes used by the tileset.
    this.stats = new Stats({id: this.url});
    this._initializeStats();

    this.tilesetInitializationPromise = this._initializeTileSet(tileset);
  }

  /** Release resources */
  destroy(): void {
    this._destroy();
  }

  /** Is the tileset loaded (update needs to have been called at least once) */
  isLoaded(): boolean {
    // Check that `_frameNumber !== 0` which means that update was called at least once
    return this._pendingCount === 0 && this._frameNumber !== 0 && this._requestedTiles.length === 0;
  }

  get tiles(): object[] {
    return Object.values(this._tiles);
  }

  get frameNumber(): number {
    return this._frameNumber;
  }

  get queryParams(): string {
    return new URLSearchParams(this._queryParams).toString();
  }

  setProps(props: Tileset3DProps): void {
    this.options = {...this.options, ...props};
  }

  /** @deprecated */
  // setOptions(options: Tileset3DProps): void {
  //   this.options = {...this.options, ...options};
  // }

  /**
   * Return a loadable tile url for a specific tile subpath
   * @param tilePath a tile subpath
   */
  getTileUrl(tilePath: string): string {
    const isDataUrl = tilePath.startsWith('data:');
    if (isDataUrl) {
      return tilePath;
    }

    let tileUrl = tilePath;
    if (this.queryParams.length) {
      tileUrl = `${tilePath}${tilePath.includes('?') ? '&' : '?'}${this.queryParams}`;
    }
    return tileUrl;
  }

  // TODO CESIUM specific
  hasExtension(extensionName: string): boolean {
    return Boolean(this._extensionsUsed.indexOf(extensionName) > -1);
  }

  /**
   * Update visible tiles relying on a list of viewports
   * @param viewports - list of viewports
   * @deprecated
   */
  update(viewports: Viewport[] | Viewport | null = null) {
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    this.tilesetInitializationPromise.then(() => {
      if (!viewports && this.lastUpdatedVieports) {
        viewports = this.lastUpdatedVieports;
      } else {
        this.lastUpdatedVieports = viewports;
      }
      if (viewports) {
        this.doUpdate(viewports);
      }
    });
  }

  /**
   * Update visible tiles relying on a list of viewports.
   * Do it with debounce delay to prevent update spam
   * @param viewports viewports
   * @returns Promise of new frameNumber
   */
  async selectTiles(viewports: Viewport[] | Viewport | null = null): Promise<number> {
    await this.tilesetInitializationPromise;
    if (viewports) {
      this.lastUpdatedVieports = viewports;
    }
    if (!this.updatePromise) {
      this.updatePromise = new Promise<number>((resolve) => {
        setTimeout(() => {
          if (this.lastUpdatedVieports) {
            this.doUpdate(this.lastUpdatedVieports);
          }
          resolve(this._frameNumber);
          this.updatePromise = null;
        }, this.options.debounceTime);
      });
    }
    return this.updatePromise;
  }

  adjustScreenSpaceError(): void {
    if (this.gpuMemoryUsageInBytes < this._cacheBytes) {
      this.memoryAdjustedScreenSpaceError = Math.max(
        this.memoryAdjustedScreenSpaceError / 1.02,
        this.options.maximumScreenSpaceError
      );
    } else if (this.gpuMemoryUsageInBytes > this._cacheBytes + this._cacheOverflowBytes) {
      this.memoryAdjustedScreenSpaceError *= 1.02;
    }
  }
  /**
   * Update visible tiles relying on a list of viewports
   * @param viewports viewports
   */
  // eslint-disable-next-line max-statements, complexity
  private doUpdate(viewports: Viewport[] | Viewport): void {
    if ('loadTiles' in this.options && !this.options.loadTiles) {
      return;
    }
    if (this.traverseCounter > 0) {
      return;
    }
    const preparedViewports = viewports instanceof Array ? viewports : [viewports];

    this._cache.reset();
    this._frameNumber++;
    this.traverseCounter = preparedViewports.length;
    const viewportsToTraverse: string[] = [];
    // First loop to decrement traverseCounter
    for (const viewport of preparedViewports) {
      const id = viewport.id;
      if (this._needTraverse(id)) {
        viewportsToTraverse.push(id);
      } else {
        this.traverseCounter--;
      }
    }

    // Second loop to traverse
    for (const viewport of preparedViewports) {
      const id = viewport.id;
      if (!this.roots[id]) {
        this.roots[id] = this._initializeTileHeaders(this.tileset, null);
      }

      if (!viewportsToTraverse.includes(id)) {
        continue; // eslint-disable-line no-continue
      }
      const frameState = getFrameState(viewport as GeospatialViewport, this._frameNumber);
      this._traverser.traverse(this.roots[id], frameState, this.options);
    }
  }

  /**
   * Check if traversal is needed for particular viewport
   * @param {string} viewportId - id of a viewport
   * @return {boolean}
   */
  _needTraverse(viewportId: string): boolean {
    let traverserId = viewportId;
    if (this.options.viewportTraversersMap) {
      traverserId = this.options.viewportTraversersMap[viewportId];
    }
    if (traverserId !== viewportId) {
      return false;
    }

    return true;
  }

  /**
   * The callback to post-process tiles after traversal procedure
   * @param frameState - frame state for tile culling
   */
  _onTraversalEnd(frameState: FrameState): void {
    const id = frameState.viewport.id;
    if (!this.frameStateData[id]) {
      this.frameStateData[id] = {selectedTiles: [], _requestedTiles: [], _emptyTiles: []};
    }
    const currentFrameStateData = this.frameStateData[id];
    const selectedTiles = Object.values(this._traverser.selectedTiles);
    const [filteredSelectedTiles, unselectedTiles] = limitSelectedTiles(
      selectedTiles,
      frameState,
      this.options.maximumTilesSelected
    );
    currentFrameStateData.selectedTiles = filteredSelectedTiles;
    for (const tile of unselectedTiles) {
      tile.unselect();
    }

    currentFrameStateData._requestedTiles = Object.values(this._traverser.requestedTiles);
    currentFrameStateData._emptyTiles = Object.values(this._traverser.emptyTiles);

    this.traverseCounter--;
    if (this.traverseCounter > 0) {
      return;
    }

    this._updateTiles();
  }

  /**
   * Update tiles relying on data from all traversers
   */
  _updateTiles(): void {
    this.selectedTiles = [];
    this._requestedTiles = [];
    this._emptyTiles = [];

    for (const frameStateKey in this.frameStateData) {
      const frameStateDataValue = this.frameStateData[frameStateKey];
      this.selectedTiles = this.selectedTiles.concat(frameStateDataValue.selectedTiles);
      this._requestedTiles = this._requestedTiles.concat(frameStateDataValue._requestedTiles);
      this._emptyTiles = this._emptyTiles.concat(frameStateDataValue._emptyTiles);
    }

    this.selectedTiles = this.options.onTraversalComplete(this.selectedTiles);

    for (const tile of this.selectedTiles) {
      this._tiles[tile.id] = tile;
    }

    this._loadTiles();
    this._unloadTiles();
    this._updateStats();
  }

  _tilesChanged(oldSelectedTiles: Tile3D[], selectedTiles: Tile3D[]): boolean {
    if (oldSelectedTiles.length !== selectedTiles.length) {
      return true;
    }
    const set1 = new Set(oldSelectedTiles.map((t) => t.id));
    const set2 = new Set(selectedTiles.map((t) => t.id));
    let changed = oldSelectedTiles.filter((x) => !set2.has(x.id)).length > 0;
    changed = changed || selectedTiles.filter((x) => !set1.has(x.id)).length > 0;
    return changed;
  }

  _loadTiles(): void {
    // Sort requests by priority before making any requests.
    // This makes it less likely this requests will be cancelled after being issued.
    // requestedTiles.sort((a, b) => a._priority - b._priority);
    for (const tile of this._requestedTiles) {
      if (tile.contentUnloaded) {
        // eslint-disable-next-line @typescript-eslint/no-floating-promises
        this._loadTile(tile);
      }
    }
  }

  _unloadTiles(): void {
    // unload tiles from cache when hit maximumMemoryUsage
    this._cache.unloadTiles(this, (tileset, tile) => tileset._unloadTile(tile));
  }

  _updateStats(): void {
    let tilesRenderable = 0;
    let pointsRenderable = 0;
    for (const tile of this.selectedTiles) {
      if (tile.contentAvailable && tile.content) {
        tilesRenderable++;
        if (tile.content.pointCount) {
          pointsRenderable += tile.content.pointCount;
        } else {
          // Calculate vertices for non point cloud tiles.
          pointsRenderable += tile.content.vertexCount;
        }
      }
    }

    this.stats.get(TILES_IN_VIEW).count = this.selectedTiles.length;
    this.stats.get(TILES_RENDERABLE).count = tilesRenderable;
    this.stats.get(POINTS_COUNT).count = pointsRenderable;
    this.stats.get(MAXIMUM_SSE).count = this.memoryAdjustedScreenSpaceError;
  }

  async _initializeTileSet(tilesetJson: TilesetJSON): Promise<void> {
    if (this.type === TILESET_TYPE.I3S) {
      this.calculateViewPropsI3S();
      tilesetJson.root = await tilesetJson.root;
    }
    this.root = this._initializeTileHeaders(tilesetJson, null);

    if (this.type === TILESET_TYPE.TILES3D) {
      this._initializeTiles3DTileset(tilesetJson);
      this.calculateViewPropsTiles3D();
    }

    if (this.type === TILESET_TYPE.I3S) {
      this._initializeI3STileset();
    }
  }

  /**
   * Called during initialize Tileset to initialize the tileset's cartographic center (longitude, latitude) and zoom.
   * These metrics help apps center view on tileset
   * For I3S there is extent (<1.8 version) or fullExtent (>=1.8 version) to calculate view props
   * @returns
   */
  private calculateViewPropsI3S(): void {
    // for I3S 1.8 try to calculate with fullExtent
    const fullExtent = this.tileset.fullExtent;
    if (fullExtent) {
      const {xmin, xmax, ymin, ymax, zmin, zmax} = fullExtent;
      this.cartographicCenter = new Vector3(
        xmin + (xmax - xmin) / 2,
        ymin + (ymax - ymin) / 2,
        zmin + (zmax - zmin) / 2
      );
      this.cartesianCenter = new Vector3();
      Ellipsoid.WGS84.cartographicToCartesian(this.cartographicCenter, this.cartesianCenter);
      this.zoom = getZoomFromFullExtent(fullExtent, this.cartographicCenter, this.cartesianCenter);
      return;
    }
    // for I3S 1.6-1.7 try to calculate with extent
    const extent = this.tileset.store?.extent;
    if (extent) {
      const [xmin, ymin, xmax, ymax] = extent;
      this.cartographicCenter = new Vector3(xmin + (xmax - xmin) / 2, ymin + (ymax - ymin) / 2, 0);
      this.cartesianCenter = new Vector3();
      Ellipsoid.WGS84.cartographicToCartesian(this.cartographicCenter, this.cartesianCenter);
      this.zoom = getZoomFromExtent(extent, this.cartographicCenter, this.cartesianCenter);
      return;
    }
    // eslint-disable-next-line no-console
    console.warn('Extent is not defined in the tileset header');
    this.cartographicCenter = new Vector3();
    this.zoom = 1;
    return;
  }

  /**
   * Called during initialize Tileset to initialize the tileset's cartographic center (longitude, latitude) and zoom.
   * These metrics help apps center view on tileset.
   * For 3DTiles the root tile data is used to calculate view props.
   * @returns
   */
  private calculateViewPropsTiles3D() {
    const root = this.root as Tile3D;
    const {center} = root.boundingVolume;
    // TODO - handle all cases
    if (!center) {
      // eslint-disable-next-line no-console
      console.warn('center was not pre-calculated for the root tile');
      this.cartographicCenter = new Vector3();
      this.zoom = 1;
      return;
    }

    // cartographic coordinates are undefined at the center of the ellipsoid
    if (center[0] !== 0 || center[1] !== 0 || center[2] !== 0) {
      this.cartographicCenter = new Vector3();
      Ellipsoid.WGS84.cartesianToCartographic(center, this.cartographicCenter);
    } else {
      this.cartographicCenter = new Vector3(0, 0, -Ellipsoid.WGS84.radii[0]);
    }
    this.cartesianCenter = center;
    this.zoom = getZoomFromBoundingVolume(root.boundingVolume, this.cartographicCenter);
  }

  _initializeStats() {
    this.stats.get(TILES_TOTAL);
    this.stats.get(TILES_LOADING);
    this.stats.get(TILES_IN_MEMORY);
    this.stats.get(TILES_IN_VIEW);
    this.stats.get(TILES_RENDERABLE);
    this.stats.get(TILES_LOADED);
    this.stats.get(TILES_UNLOADED);
    this.stats.get(TILES_LOAD_FAILED);
    this.stats.get(POINTS_COUNT);
    this.stats.get(TILES_GPU_MEMORY, 'memory');
    this.stats.get(MAXIMUM_SSE);
  }

  // Installs the main tileset JSON file or a tileset JSON file referenced from a tile.
  // eslint-disable-next-line max-statements
  _initializeTileHeaders(tilesetJson: TilesetJSON, parentTileHeader?: any) {
    // A tileset JSON file referenced from a tile may exist in a different directory than the root tileset.
    // Get the basePath relative to the external tileset.
    const rootTile = new Tile3D(this, tilesetJson.root, parentTileHeader); // resource

    // If there is a parentTileHeader, add the root of the currently loading tileset
    // to parentTileHeader's children, and update its depth.
    if (parentTileHeader) {
      parentTileHeader.children.push(rootTile);
      rootTile.depth = parentTileHeader.depth + 1;
    }

    // 3DTiles knows the hierarchy beforehand
    if (this.type === TILESET_TYPE.TILES3D) {
      const stack: Tile3D[] = [];
      stack.push(rootTile);

      while (stack.length > 0) {
        const tile = stack.pop() as Tile3D;
        this.stats.get(TILES_TOTAL).incrementCount();
        const children = tile.header.children || [];
        for (const childHeader of children) {
          const childTile = new Tile3D(this, childHeader, tile);

          // Special handling for Google
          // A session key must be used for all tile requests
          if (childTile.contentUrl?.includes('?session=')) {
            const url = new URL(childTile.contentUrl);
            const session = url.searchParams.get('session');
            // eslint-disable-next-line max-depth
            if (session) {
              this._queryParams.session = session;
            }
          }

          tile.children.push(childTile);
          childTile.depth = tile.depth + 1;
          stack.push(childTile);
        }
      }
    }

    return rootTile;
  }

  _initializeTraverser(): TilesetTraverser {
    let TraverserClass;
    const type = this.type;
    switch (type) {
      case TILESET_TYPE.TILES3D:
        TraverserClass = Tileset3DTraverser;
        break;
      case TILESET_TYPE.I3S:
        TraverserClass = I3STilesetTraverser;
        break;
      default:
        TraverserClass = TilesetTraverser;
    }

    return new TraverserClass({
      basePath: this.basePath,
      onTraversalEnd: this._onTraversalEnd.bind(this)
    });
  }

  _destroyTileHeaders(parentTile: Tile3D): void {
    this._destroySubtree(parentTile);
  }

  async _loadTile(tile: Tile3D): Promise<void> {
    let loaded;
    try {
      this._onStartTileLoading();
      loaded = await tile.loadContent();
    } catch (error: unknown) {
      this._onTileLoadError(tile, error instanceof Error ? error : new Error('load failed'));
    } finally {
      this._onEndTileLoading();
      this._onTileLoad(tile, loaded);
    }
  }

  _onTileLoadError(tile: Tile3D, error: Error): void {
    this.stats.get(TILES_LOAD_FAILED).incrementCount();

    const message = error.message || error.toString();
    const url = tile.url;
    // TODO - Allow for probe log to be injected instead of console?
    console.error(`A 3D tile failed to load: ${tile.url} ${message}`); // eslint-disable-line
    this.options.onTileError(tile, message, url);
  }

  _onTileLoad(tile: Tile3D, loaded: boolean): void {
    if (!loaded) {
      return;
    }

    if (this.type === TILESET_TYPE.I3S) {
      // We can't calculate tiles total in I3S in advance so we calculate it dynamically.
      const nodesInNodePages = this.tileset?.nodePagesTile?.nodesInNodePages || 0;
      this.stats.get(TILES_TOTAL).reset();
      this.stats.get(TILES_TOTAL).addCount(nodesInNodePages);
    }

    // add coordinateOrigin and modelMatrix to tile
    if (tile && tile.content) {
      calculateTransformProps(tile, tile.content);
    }

    this.updateContentTypes(tile);
    this._addTileToCache(tile);
    this.options.onTileLoad(tile);
  }

  /**
   * Update information about data types in nested tiles
   * @param tile instance of a nested Tile3D
   */
  private updateContentTypes(tile: Tile3D) {
    if (this.type === TILESET_TYPE.I3S) {
      if (tile.header.isDracoGeometry) {
        this.contentFormats.draco = true;
      }
      switch (tile.header.textureFormat) {
        case 'dds':
          this.contentFormats.dds = true;
          break;
        case 'ktx2':
          this.contentFormats.ktx2 = true;
          break;
        default:
      }
    } else if (this.type === TILESET_TYPE.TILES3D) {
      const {extensionsRemoved = []} = tile.content?.gltf || {};
      if (extensionsRemoved.includes('KHR_draco_mesh_compression')) {
        this.contentFormats.draco = true;
      }
      if (extensionsRemoved.includes('EXT_meshopt_compression')) {
        this.contentFormats.meshopt = true;
      }
      if (extensionsRemoved.includes('KHR_texture_basisu')) {
        this.contentFormats.ktx2 = true;
      }
    }
  }

  _onStartTileLoading() {
    this._pendingCount++;
    this.stats.get(TILES_LOADING).incrementCount();
  }

  _onEndTileLoading() {
    this._pendingCount--;
    this.stats.get(TILES_LOADING).decrementCount();
  }

  _addTileToCache(tile: Tile3D) {
    this._cache.add(this, tile, (tileset) => tileset._updateCacheStats(tile));
  }

  _updateCacheStats(tile) {
    this.stats.get(TILES_LOADED).incrementCount();
    this.stats.get(TILES_IN_MEMORY).incrementCount();

    // TODO: Calculate GPU memory usage statistics for a tile.
    this.gpuMemoryUsageInBytes += tile.gpuMemoryUsageInBytes || 0;

    this.stats.get(TILES_GPU_MEMORY).count = this.gpuMemoryUsageInBytes;

    // Adjust SSE based on cache limits
    if (this.options.memoryAdjustedScreenSpaceError) {
      this.adjustScreenSpaceError();
    }
  }

  _unloadTile(tile) {
    this.gpuMemoryUsageInBytes -= tile.gpuMemoryUsageInBytes || 0;

    this.stats.get(TILES_IN_MEMORY).decrementCount();
    this.stats.get(TILES_UNLOADED).incrementCount();
    this.stats.get(TILES_GPU_MEMORY).count = this.gpuMemoryUsageInBytes;

    this.options.onTileUnload(tile);
    tile.unloadContent();
  }

  // Traverse the tree and destroy all tiles
  _destroy() {
    const stack: Tile3D[] = [];

    if (this.root) {
      stack.push(this.root);
    }

    while (stack.length > 0) {
      const tile: Tile3D = stack.pop() as Tile3D;

      for (const child of tile.children) {
        stack.push(child);
      }

      this._destroyTile(tile);
    }
    this.root = null;
  }

  // Traverse the tree and destroy all sub tiles
  _destroySubtree(tile) {
    const root = tile;
    const stack: Tile3D[] = [];
    stack.push(root);
    while (stack.length > 0) {
      tile = stack.pop();
      for (const child of tile.children) {
        stack.push(child);
      }
      if (tile !== root) {
        this._destroyTile(tile);
      }
    }
    root.children = [];
  }

  _destroyTile(tile) {
    this._cache.unloadTile(this, tile);
    this._unloadTile(tile);
    tile.destroy();
  }

  _initializeTiles3DTileset(tilesetJson) {
    if (tilesetJson.queryString) {
      const searchParams = new URLSearchParams(tilesetJson.queryString);
      const queryParams = Object.fromEntries(searchParams.entries());
      this._queryParams = {...this._queryParams, ...queryParams};
    }

    this.asset = tilesetJson.asset;
    if (!this.asset) {
      throw new Error('Tileset must have an asset property.');
    }
    if (
      this.asset.version !== '0.0' &&
      this.asset.version !== '1.0' &&
      this.asset.version !== '1.1'
    ) {
      throw new Error('The tileset must be 3D Tiles version either 0.0 or 1.0 or 1.1.');
    }

    // Note: `asset.tilesetVersion` is version of the tileset itself (not the version of the 3D TILES standard)
    // We add this version as a `v=1.0` query param to fetch the right version and not get an older cached version
    if ('tilesetVersion' in this.asset) {
      this._queryParams.v = this.asset.tilesetVersion;
    }

    // TODO - ion resources have a credits property we can use for additional attribution.
    this.credits = {
      attributions: this.options.attributions || []
    };
    this.description = this.options.description || '';

    // Gets the tileset's properties dictionary object, which contains metadata about per-feature properties.
    this.properties = tilesetJson.properties;
    this.geometricError = tilesetJson.geometricError;
    this._extensionsUsed = tilesetJson.extensionsUsed || [];
    // Returns the extras property at the top of the tileset JSON (application specific metadata).
    this.extras = tilesetJson.extras;
  }

  _initializeI3STileset() {
    // @ts-expect-error
    if (this.loadOptions.i3s && 'token' in this.loadOptions.i3s) {
      // @ts-ignore
      this._queryParams.token = this.loadOptions.i3s.token as string;
    }
  }
}
