parks/dlp/disneylandparis.js

import {v4 as uuid} from 'uuid';
import {Destination} from '../destination.js';
import {attractionType, statusType, queueType, scheduleType, returnTimeState, entityType} from '../parkTypes.js';
import moment from 'moment-timezone';
import sift from 'sift';

const ignoreEntities = [
  '00000', // Avengers Campus
  'P1NA18', // duplicate princess pavilion
  'test2', // test entity
  'P2AC00-REMOVED', // removed entity
  'P2AC00', // Avenger's Campus land
  'armageddon', // Defunct Armageddon attraction
];

/**
 * Disneyland Paris Park Object
 */
export class DisneylandParis extends Destination {
  /**
   * Create a new DisneylandParis object
   * @param {object} options
   */
  constructor(options = {}) {
    options.name = options.name || 'Disneyland Paris';
    options.timezone = options.timezone || 'Europe/Paris';

    options.apiKey = options.apiKey || '';
    options.apiBase = options.apiBase || '';
    options.apiBaseWaitTimes = options.apiBaseWaitTimes || '';
    options.language = options.language || 'en-gb';

    options.standbyApiBase = options.standbyApiBase || '';
    options.standbyApiKey = options.standbyApiKey || '';
    options.standbyAuthURL = options.standbyAuthURL || '';

    options.standbyApiRefreshToken = options.standbyApiRefreshToken || '';

    options.premierAccessApiKey = options.premierAccessApiKey || '';
    options.premierAccessURL = options.premierAccessURL || '';

    options.useragent = options.useragent || 'okhttp/3.12.1';

    options.configPrefixes = ['DLP'].concat(options.configPrefixes || []);

    options.cacheVersion = 3;

    super(options);

    if (!this.config.apiKey) throw new Error('Missing Disneyland Paris apiKey');
    if (!this.config.apiBase) throw new Error('Missing Disneyland Paris apiBase');
    if (!this.config.apiBaseWaitTimes) throw new Error('Missing Disneyland Paris apiBaseWaitTimes');

    if (!this.config.standbyApiBase) throw new Error('Missing Disneyland Paris standbyApiBase');
    if (!this.config.standbyApiKey) throw new Error('Missing Disneyland Paris standbyApiKey');
    if (!this.config.standbyAuthURL) throw new Error('Missing Disneyland Paris standbyAuthURL');

    if (!this.config.standbyApiRefreshToken) {
      console.log(`Missing DLP standby API token - will be missing standby (virtual) queue data`);
    }

    if (!this.config.premierAccessURL) throw new Error('Missing Disneyland Paris premierAccessURL');
    if (!this.config.premierAccessApiKey) throw new Error('Missing Disneyland Paris premierAccessApiKey');

    // attraction data domain
    this.http.injectForDomain({
      hostname: new URL(this.config.apiBase).hostname,
    }, async (method, url, data, options) => {
      options.headers['x-application-id'] = 'mobile-app';
      options.headers['x-request-id'] = uuid();
      options.json = true;
    });

    // live wait time domain
    this.http.injectForDomain({
      hostname: new URL(this.config.apiBaseWaitTimes).hostname,
    }, async (method, url, data, options) => {
      options.headers['x-api-key'] = this.config.apiKey,
        options.headers.accept = 'application/json, text/plain, */*';
    });

    // virtual queue domain
    this.http.injectForDomain({
      hostname: new URL(this.config.standbyApiBase).hostname,
    }, async (method, url, data, options) => {
      const authData = await this.getAuthToken();
      if (!authData) {
        throw new Error(`Unable to get auth token for DLP virtual queue access: ${JSON.stringify(authData)}`);
      }

      options.headers['x-api-key'] = this.config.standbyApiKey;
      options.headers['authorization'] = `BEARER ${authData}`;
      options.headers.accept = 'application/json, text/plain, */*';
    });

    this.http.injectForDomainResponse({
      $or: [
        {hostname: new URL(this.config.standbyApiBase).hostname},
        {hostname: new URL(this.config.premierAccessURL).hostname}
      ],
    }, async (resp) => {
      if (resp.statusCode === 401) {
        // unset our api key so we refetch it
        console.log('Failed to get vqueue, fetch our auth key again...');
        await this.cache.set('dlp_apikey', undefined, -1);
        return undefined;
      }

      return resp;
    });

    this.http.injectForDomainResponse({
      $or: [
        {hostname: new URL(this.config.standbyApiBase).hostname},
        {hostname: new URL(this.config.premierAccessURL).hostname},
        {hostname: new URL(this.config.standbyAuthURL).hostname},
      ],
    }, async (resp) => {
      if (resp.statusCode === 400) {
        // fetch our API key and try again
        await this.cache.set('dlp_authapikey', undefined, -1);
        return undefined;
      }

      return resp;
    });

    // premier access domain
    this.http.injectForDomain({
      hostname: new URL(this.config.premierAccessURL).hostname,
    }, async (method, url, data, options) => {
      options.headers['x-api-key'] = this.config.premierAccessApiKey;
      options.headers.accept = 'application/json, text/plain, */*';
    });
  }

  /*
  async _buildAttractionObject(attractionID) {
    const parkData = await this.getParkData();

    const attr = parkData.find((x) => {
      return x.id === attractionID;
    });
    if (attr === undefined) return undefined;

    // attraction tags
    const tags = [];

    // attraction have fastpass?
    tags.push({
      type: tagType.fastPass,
      value: !!attr.fastPass,
    });

    // on-ride photos?
    tags.push({
      type: tagType.onRidePhoto,
      value: !!attr.photopass,
    });

    // single rider queue available?
    tags.push({
      type: tagType.singleRider,
      value: !!attr.singleRider,
    });

    // location
    if (attr.coordinates) {
      const entrance = attr.coordinates.find((x) => x.type === 'Guest Entrance');
      if (entrance) {
        tags.push({
          type: tagType.location,
          key: 'location',
          value: {
            longitude: entrance.lng,
            latitude: entrance.lat,
          },
        });
      }
    }

    // height tag
    if (attr.height !== undefined) {
      attr.height.forEach((height) => {
        // skip attractions for "any height"
        if (height.id === 'anyHeight') return;

        const heightVal = /([\d\.]+)\s+(\w+)/.exec(height.value);
        if (heightVal) {
          const unit = heightVal[2];
          tags.push({
            type: tagType.minimumHeight,
            key: height.id,
            value: {
              height: Number(heightVal[1]) * (unit === 'm' ? 100 : 1),
              unit: 'cm',
            },
          });
        }
      });
    }

    // may get wet
    tags.push({
      type: tagType.mayGetWet,
      value: attr.interests ? !!attr.interests.find((x) => x.id === 'guestMayGetSplashed') : false,
    });

    // pregnant riders
    tags.push({
      type: tagType.unsuitableForPregnantPeople,
      value: attr.physicalConsiderations ?
        !!attr.physicalConsiderations.find((x) => x.id === 'expectantMothersMayNotRide') :
        false,
    });

    return {
      name: attr.name,
      // TODO - sort rides from other attractions
      type: attr.contentType === 'Attraction' ? attractionType.ride : attractionType.other,
      tags,
    };
  }*/

  /**
   * Get API key for authentication
   * @returns {string}
   */
  async getAuthApiKey() {
    return await this.cache.wrap(`dlp_authapikey`, async () => {
      const data = await this.http('POST', `${this.config.standbyAuthURL}api-key`, null);
      return data?.headers?.['api-key'];
    }, 1000 * 60 * 60 * 24); // 24-hours, will be refreshed if we need to fetch a new one
  }

  /**
   * Get our latest refresh token
   */
  async getRefreshToken() {
    // return refresh token from env or local cache
    return await this.cache.wrap(`refreshtoken`, async () => {
      return this.config.standbyApiRefreshToken;
    }, 1000 * 60 * 60 * 24 * 180); // keep using cached token for 180 days
    //  we update our token in refreshAuthToken with updated token values
  }

  /**
   * Use a refresh token to get a new token object
   */
  async refreshAuthToken(refreshToken) {
    this.log('Refreshing DLP token...');
    const apiKey = await this.getAuthApiKey();

    const resp = await this.http('POST', `${this.config.standbyAuthURL}guest/refresh-auth`, {
      'refreshToken': refreshToken,
    }, {
      headers: {
        'x-api-key': apiKey,
        'authorization': `APIKEY ${apiKey}`,
        'x-requested-with': 'fr.disneylandparis.android',
        "user-agent": 'okhttp/3.14.7',
        "cache-control": "no-cache",
        "accept-language": "en-gb",
      },
      json: true,
    });

    this.log(`Receieved new refresh token ${resp?.body?.data?.token?.refresh_token}`);

    // if we get a new refresh token, store it for later use
    if (resp?.body?.data?.token?.refresh_token) {
      await this.cache.set('refreshtoken', resp?.body?.data?.token?.refresh_token, 1000 * 60 * 60 * 24 * 180);
    }

    return resp?.body?.data?.token;
  }

  /**
   * Fetch our auth token to access the HTTP API
   * @returns {string}
   */
  async getAuthToken() {
    let expiryTime = 1000 * 60 * 60; // default: 1 hour
    return await this.cache.wrap(`authtoken`, async () => {
      // use refresh token to fetch new auth token
      const token = await this.refreshAuthToken(this.config.standbyApiRefreshToken);

      expiryTime = token.ttl * 1000;

      return token.access_token;
    }, () => {
      return expiryTime;
    });
  }

  /**
   * Get park POI data
   * @returns <object>
   */
  async getPOIData() {
    // cache 12 hours
    '@cache|720';

    const entityProps = `id
    name
    type: __typename
    hideFunctionality
    location {
      id
      value
    }
    coordinates {
      lat
      lng
    }
    schedules {
      language
      date
      startTime
      endTime
      status
      closed
    }
    subType`;

    const fetchedData = await this.http('POST', `${this.config.apiBase}/query`, {
      // eslint-disable-next-line max-len
      query: `
      query activities($market: String!) {
        Attraction: activities(market: $market, types: "Attraction") {
          ${entityProps}
        }
        ,
        DiningEvent: activities(market: $market, types: "DiningEvent") {
          ${entityProps}
        }
        ,
        DinnerShow: activities(market: $market, types: "DinnerShow") {
          ${entityProps}
        }
        ,
        Entertainment: activities(market: $market, types: "Entertainment") {
          ${entityProps}
        }
        ,
        Event: activities(market: $market, types: "Event") {
          ${entityProps}
        }
        ,
        GuestService: activities(market: $market, types: "GuestService") {
          ${entityProps}
        }
        ,
        Recreation: activities(market: $market, types: "Recreation") {
          ${entityProps}
        }
        ,
        Resort: activities(market: $market, types: "Resort") {
          ${entityProps}
        }
        ,
        Restaurant: activities(market: $market, types: "Restaurant") {
          ${entityProps}
        }
        ,
        Shop: activities(market: $market, types: "Shop") {
          ${entityProps}
        }
        ,
        Spa: activities(market: $market, types: "Spa") {
          ${entityProps}
        }
        ,
        Tour: activities(market: $market, types: "Tour") {
          ${entityProps}
        },
        ThemePark: activities(market: $market, types: "ThemePark") {
          ${entityProps}
        }
      }
      `,
      variables: {
        market: this.config.language,
      },
    });

    return fetchedData.body.data;
  }

  /**
   * Helper function to build a basic entity document
   * Useful to avoid copy/pasting
   * @param {object} data 
   * @returns {object}
   */
  buildBaseEntityObject(data) {
    const entity = Destination.prototype.buildBaseEntityObject.call(this, data);

    entity._id = data?.id;
    entity.name = data?.name;
    entity._tags = [];

    // try and find location data
    if (data?.coordinates) {
      const entrance = data.coordinates.find((x) => x.type === 'Guest Entrance' || x.type === undefined);
      if (entrance) {
        entity.location = {
          longitude: entrance.lng,
          latitude: entrance.lat,
        };
      }
    }

    // facets
    if (data?.height) {
      const noHeightLimit = !!data.height.find((x) => x.id === 'anyHeight');
      if (!noHeightLimit) {
        // TODO - find mix/max height
        const minHeightData = data.height.find((x) => x.iconFont.indexOf('min-height') > -1);
        if (minHeightData) {
          const val = minHeightData.value.split(' ');
          if (val[1] === 'm') {
            entity._tags.push({
              id: 'minimumHeight',
              value: Number(Number(val[0]) * 100),
            });
          } else if (val[1] === 'cm') {
            entity._tags.push({
              id: 'minimumHeight',
              value: Number(Number(val[0])),
            });
          } else {
            // TODO - emit error
            console.error('Unknown height unit', val);
          }
        }
      } else {
        entity._tags.push({
          id: 'minimumHeight',
          value: 0,
        });
      }
    }

    // this policy line takes priotity over any other tags
    if (data?.guestPolicies) {
      if (data.guestPolicies.indexOf('Expectant Mothers may not ride') >= 0) {
        entity._tags.push({
          id: 'suitableForPregnantPeople',
          value: false,
        });
      }
    }
    if (!entity._tags.find((x) => x.id === 'suitableForPregnantPeople') && data?.mobilityDisabilities) {
      // find pregnancy tag
      const pregnancy = data.mobilityDisabilities.find((x) => x.id === 'accessibleToPregnantWomen');
      if (pregnancy) {
        // major discrepencies between accessibility brochure (https://brochure.disneylandparis.com/HCP/UK/catalogue/index.html)
        //  and the DLP app. Not comfortable in reporting any ride as "suitable".
        // will keep an eye on, and likely manually evaluate rides as app data is useless here.
        /*entity._tags.push({
          id: 'suitableForPregnantPeople',
          value: true,
        });*/
      }
    }
    if (!entity._tags.find((x) => x.id === 'suitableForPregnantPeople') && data?.physicalConsiderations) {
      // find rides explicitly marked as not suitable for pregnant people
      const pregnancy = data.physicalConsiderations.find((x) => x.id === 'expectantMothersMayNotRide');
      if (pregnancy) {
        entity._tags.push({
          id: 'suitableForPregnantPeople',
          value: false,
        });
      }
    }

    return entity;
  }

  /**
   * Build the destination entity representing this destination
   */
  async buildDestinationEntity() {
    return {
      ...this.buildBaseEntityObject(),
      _id: 'dlp',
      name: this.config.name,
      slug: 'disneylandparis',
      entityType: entityType.destination,
    };
  }

  /**
   * Build the park entities for this destination
   */
  async buildParkEntities() {
    // find all destination parks
    const poiData = await this.getPOIData();
    const parks = poiData.ThemePark;

    // parks like to vanish from the POI data sometimes (???)
    //  inject Studios manually into returned data if it's not in the API
    if (parks.findIndex((x) => x.id === 'P2') === -1) {
      parks.push({
        "id": "P2",
        "name": "Walt Disney Studios Park",
        "coordinates": [
          {
            "lat": 48.868391,
            "lng": 2.780802,
            "type": "Guest Entrance"
          }
        ],
      });
    }

    const destination = await this.buildDestinationEntity();

    return parks.map((x) => {
      return {
        ...this.buildBaseEntityObject(x),
        _destinationId: destination._id,
        _parentId: destination._id,
        slug: x.name.toLowerCase().replace(/[^a-z]/g, ''),
        entityType: entityType.park,
      };
    });
  }

  async filterPOIData(filterObj) {
    const parkData = await this.getPOIData();
    if (!parkData) return [];

    const hideRules = [
      'Hide from Web List + Mobile App',
      'Hide from the Service',
      'Hide from Mobile App',
    ];

    // reduce fields
    const attrs = Object.keys(parkData).reduce((prev, curr) => {
      return [
        ...prev,
        ...parkData[curr].map((x) => {
          // inject category from field name
          return {
            ...x,
            category: curr,
          };
        })
      ];
    }, []).filter((x) => {
      return x?.location?.id === 'P1' || x?.location?.id === 'P2';
    }).filter((x) => {
      // remove any entities that are "hidden from the app"
      return hideRules.indexOf(x.hideFunctionality) === -1;
    });

    return attrs.filter(sift(filterObj));
  }

  async getEntitiesOfTypes(filterObj, data) {
    const attrs = await this.filterPOIData(filterObj);

    const destination = await this.buildDestinationEntity();

    return attrs.map((x) => {
      // skip placeholders
      if (ignoreEntities.indexOf(x?.id) >= 0) return undefined;

      return {
        ...this.buildBaseEntityObject(x),
        ...data,
        _destinationId: destination._id,
        _parentId: x?.location?.id,
        _parkId: x?.location?.id,
      };
    }).filter((x) => !!x);
  }

  /**
   * Build the attraction entities for this destination
   */
  async buildAttractionEntities() {
    return this.getEntitiesOfTypes({
      category: 'Attraction',
    }, {
      entityType: entityType.attraction,
      attractionType: attractionType.ride,
    });
  }

  _getShowSubtypes() {
    return [
      "Stage Show",
      "Fireworks",
      "Atmosphere",
      "Parade",
    ];
  }

  /**
   * Build the show entities for this destination
   */
  async buildShowEntities() {
    return this.getEntitiesOfTypes({
      $or: this._getShowSubtypes().map((x) => {
        return {
          subType: x,
        };
      }),
    }, {
      entityType: entityType.show,
    });
  }

  /**
   * Build the restaurant entities for this destination
   */
  async buildRestaurantEntities() {
    return this.getEntitiesOfTypes({
      category: "Restaurant",
    }, {
      entityType: entityType.restaurant,
    });
  }

  /**
   * Get wait time raw data
   * @returns {object}
   */
  async fetchWaitData() {
    '@cache|1';
    try {
      return (await this.http('GET', `${this.config.apiBaseWaitTimes}waitTimes`)).body;
    } catch (e) {
      console.error(`DLP error: getResortWaitTimes ${e}`);
      throw e;
    }
  }

  /**
   * Fetch virtual queue status
   * @returns {array}
   */
  async fetchVirtualQueueData() {
    '@cache|1';
    // skip if we have no auth token refresher
    if (!this.config.standbyApiRefreshToken) {
      return [];
    }

    try {
      const standByVirtualData = await this.http('GET', this.config.standbyApiBase);
      return standByVirtualData.body;
    } catch (e) {
      console.error(`DLP error: getResortVirtualQueues ${e}`);
      throw e;
    }
  }

  /**
   * Fetch premier access data
   * @returns {array}
   */
  async fetchPremierAccessData() {
    '@cache|1';
    // skip if we have no premier access API Key
    if (!this.config.premierAccessApiKey) {
      return [];
    }

    try {
      const standByPremierData = await this.http('GET', this.config.premierAccessURL);
      return standByPremierData.body;
    } catch (e) {
      console.error(`DLP error: getResortPremierAccess ${e}`);
      throw e;
    }
  }

  /**
   * @inheritdoc
   */
  async buildEntityLiveData() {
    // this function should return all the live data for all entities in this destination
    const waits = await this.fetchWaitData();

    if (!Array.isArray(waits)) {
      // DLP is offline (?!)
      //  DLP servers go offline briefly most nights
      return [];
    }

    // virtual queue data
    const vQData = await this.fetchVirtualQueueData();

    // premier access data
    const premierAccessData = await this.fetchPremierAccessData();

    // today's schedule data
    const today = this.getTimeNowMoment().format('YYYY-MM-DD');
    const scheduleData = await this.fetchResortScheduleForDate(today);

    // get POI data for shows so we can use their duration data
    const showPOIData = await this.filterPOIData({
      $or: this._getShowSubtypes().map((x) => {
        return {
          subType: x,
        };
      }),
    });

    const livedata = waits.filter((x) => x.type === 'Attraction' && ignoreEntities.indexOf(x.entityId) < 0).map((time) => {
      const live = {
        _id: time.entityId,
        status: statusType.operating,
      };

      if (time.status === 'DOWN') {
        live.status = statusType.down;
      } else if (time.status === 'REFURBISHMENT') {
        // it may say "refurbishment", but the DLP app actually displays this as "Closed all day"
        //live.stats= statusType.refurbishment
        live.status = statusType.closed;
      } else if (time.status === 'CLOSED' || time.status === null) {
        live.status = statusType.closed;
      }

      // stand-by time
      live.queue = {
        [queueType.standBy]: {
          waitTime: live.status === statusType.operating ?
            Number(time.postedWaitMinutes) :
            null,
        },
      };

      // single-rider time
      if (time.singleRider?.isAvailable === true) {
        live.queue[queueType.singleRider] = {
          waitTime: live.status === statusType.operating ?
            Number(time.singleRider.singleRiderWaitMinutes) :
            null,
        };
      }

      // look for virtual queue entries
      const rideVQueue = vQData && vQData.find((x) => x?.attractionId == time.entityId);
      if (rideVQueue !== undefined) {
        live.queue[queueType.returnTime] = {
          returnStart: rideVQueue.timeSlotStartDatetime ? moment.tz(rideVQueue.timeSlotStartDatetime, 'YYYY-MM-DD HH:mm', this.config.timezone).format() : null,
          returnEnd: rideVQueue.timeSlotEndDatetime ? moment.tz(rideVQueue.timeSlotEndDatetime, 'YYYY-MM-DD HH:mm', this.config.timezone).format() : null,
          state: rideVQueue.uiStatus === 'Available' ? returnTimeState.available : returnTimeState.finished,
        };
      }

      // look for premier access entries
      const access = premierAccessData && premierAccessData.find((x) => x?.attractionId == time.entityId);
      if (access !== undefined) {
        live.queue[queueType.paidReturnTime] = {
          returnStart: access.nextTimeSlotStartDateTime ? moment.tz(access.nextTimeSlotStartDateTime, this.config.timezone).format() : null,
          returnEnd: access.nextTimeSlotEndDateTime ? moment.tz(access.nextTimeSlotEndDateTime, this.config.timezone).format() : null,
          state: !!access.available ? returnTimeState.available : returnTimeState.finished,
          price: {
            currency: 'EUR',
            amount: access.price ? access.price * 100 : null,
          },
        };
      }

      return live;
    });

    // find all running shows
    scheduleData.forEach((sched) => {
      if (!sched?.schedules) return;

      const performances = sched.schedules.filter((s) => {
        return s.status === 'PERFORMANCE_TIME';
      });

      if (performances.length > 0) {
        // find show length from POI data
        let showDuration = 0;
        const show = showPOIData.find((x) => x?.id === sched.id);
        if (show && show.duration) {
          // calculate show duration in minutes
          showDuration = (show.duration.minutes || 0) + ((show.duration.hours || 0) * 60);
        }

        // generate showtime data
        const showtimes = performances.map((p) => {
          const endTimeString = showDuration === 0 ? p.endTime : p.startTime;
          return {
            startTime: moment.tz(`${today}T${p.startTime}`, 'YYYY-MM-DDTHH:mm:ss', this.config.timezone).format(),
            endTime: moment.tz(`${today}T${endTimeString}`, 'YYYY-MM-DDTHH:mm:ss', this.config.timezone).add(showDuration, 'minutes').format(),
            type: "Performance Time",
          };
        });

        // look for existing livedata and inject, or create new entry
        const existingEntry = livedata.find((x) => x._id === sched.id);
        if (existingEntry) {
          existingEntry.showtimes = showtimes;
        } else {
          livedata.push({
            _id: sched.id,
            status: statusType.operating,
            showtimes,
          });
        }
      }
    });

    return livedata;
  }

  /**
   * Fetch schedule data for a specific day
   * DLP's API returns one day at a time
   * @param {string} date YYYY-MM-DD format
   * @returns {array<schedule>}
   */
  async fetchResortScheduleForDate(date) {
    // cache for a week
    '@cache|10080';
    const fetchedData = await this.http('POST', `${this.config.apiBase}/query`, {
      // eslint-disable-next-line max-len
      'query': 'query activitySchedules($market: String!, $types: [ActivityScheduleStatusInput]!, $date: String!) { activitySchedules(market: $market, date: $date, types: $types) { __typename id name subType url pageLink { url regions { contentId templateId schemaId } } heroMediaMobile { url alt } squareMediaMobile { url alt } hideFunctionality containerTcmId urlFriendlyId location { ...location } subLocation { ...location } type subType schedules(date: $date, types: $types) { startTime endTime date status closed language } } } fragment location on Location { id value urlFriendlyId iconFont pageLink { url tcmId title regions { contentId templateId schemaId } } } ',
      'variables': {
        'market': 'en-gb',
        'types': [{
          'type': 'ThemePark',
          'status': ['OPERATING', 'EXTRA_MAGIC_HOURS'],
        }, {
          'type': 'Entertainment',
          'status': ['PERFORMANCE_TIME'],
        }, {
          'type': 'Attraction',
          'status': ['OPERATING', 'REFURBISHMENT', 'CLOSED'],
        }, {
          'type': 'Resort',
          'status': ['OPERATING', 'REFURBISHMENT', 'CLOSED'],
        }, {
          'type': 'Shop',
          'status': ['REFURBISHMENT', 'CLOSED'],
        }, {
          'type': 'Restaurant',
          'status': ['REFURBISHMENT', 'CLOSED', 'OPERATING'],
        }, {
          'type': 'DiningEvent',
          'status': ['REFURBISHMENT', 'CLOSED'],
        }, {
          'type': 'DinnerShow',
          'status': ['REFURBISHMENT', 'CLOSED'],
        }],
        'date': date,
      },
    });

    return fetchedData.body.data.activitySchedules;
  }

  /**
   * Return schedule data for all scheduled entities in this destination
   * Eg. parks
   * @returns {array<object>}
   */
  async buildEntityScheduleData() {
    // TODO - fetch schedule data some way into the future
    const now = this.getTimeNowMoment();
    const end = now.clone().add(60, 'days');

    const scheduleData = [];
    const momentParseFormat = 'YYYY-MM-DDTHH:mm:ss';

    for (; now.isSameOrBefore(end, 'day'); now.add(1, 'day')) {
      const dateString = now.format('YYYY-MM-DD');
      const dateData = await this.fetchResortScheduleForDate(dateString);
      if (!dateData) continue;

      dateData.forEach((x) => {
        if (ignoreEntities.indexOf(x?.id) >= 0) return;

        let sched = scheduleData.find((a) => a._id === x.id);
        if (!sched) {
          sched = scheduleData[scheduleData.push({
            _id: x.id,
            schedule: [],
          }) - 1];
        }

        x.schedules.forEach((hours) => {
          const open = moment.tz(`${dateString}T${hours.startTime}`, momentParseFormat, this.config.timezone);
          const close = moment.tz(`${dateString}T${hours.endTime}`, momentParseFormat, this.config.timezone);
          // handle closing times after midnight
          if (close.isBefore(open)) {
            close.add(1, 'day');
          }
          sched.schedule.push({
            date: dateString,
            openingTime: open.format(),
            closingTime: close.format(),
            // our graphql query only wants types of "OPERATING" or "EXTRA_MAGIC_HOURS", so we can ternery op this
            type: hours.status === 'EXTRA_MAGIC_HOURS' ? scheduleType.extraHours : scheduleType.operating,
            description: hours.status === 'EXTRA_MAGIC_HOURS' ? 'Extra Magic Hours' : undefined,
          });
        });
      });
    }

    return scheduleData;
  }


}

export default DisneylandParis;

/*
export class DisneylandParisMagicKingdom extends DisneylandParis {
  constructor(options = {}) {
    options.name = options.name || 'Disneyland Paris - Magic Kingdom';
    options.parkId = options.parkId || 'P1';
    super(options);
  }
}

export class DisneylandParisWaltDisneyStudios extends DisneylandParis {
  constructor(options = {}) {
    options.name = options.name || 'Disneyland Paris - Walt Disney Studios';
    options.parkId = options.parkId || 'P2';
    super(options);
  }
}
*/