parks/te2/te2.js

import {URL} from 'url';
import moment from 'moment-timezone';

import Destination from '../destination.js';
import {attractionType, statusType, queueType, scheduleType, entityType} from '../parkTypes.js';

// Ride indicator labels used to identify attractions with wait times
const RIDE_INDICATOR_LABELS = new Set(['thrill level', 'rider height', 'ages']);

// Schedule types
const SCHEDULE_TYPE_PERFORMANCE = 'Performance Time';

// Schedule label identifiers
const PARK_SCHEDULE_LABELS = ['park', 'gate'];

/**
 * Base destination for TE2-powered parks.
 * Handles basic auth using the configured username/password.
 */
export class TE2Destination extends Destination {
  /**
   * Create a new TE2Destination object
   * @param {object} options Configuration options
   * @param {string} [options.timezone] Park timezone (default: Australia/Brisbane)
   * @param {string} [options.destinationId] Unique destination identifier
   * @param {string} [options.venueId] TE2 API venue ID
   * @param {string} [options.subdomain] TE2 API subdomain
   * @param {string} [options.apidomain] TE2 API domain
   * @param {string} [options.apiuser] TE2 API username
   * @param {string} [options.apipass] TE2 API password
   * @param {Array<string>} [options.rideTypes] Categories to classify as rides
   * @param {Array<string>} [options.diningTypes] Categories to classify as dining
   * @param {Array<string>} [options.showTypes] Categories to classify as shows
   * @param {number} [options.eventScheduleDays] Days to fetch for event schedule (default: 14)
   */
  constructor(options = {}) {
    options.timezone = options.timezone || 'Australia/Brisbane';
    options.destinationId = options.destinationId || '';
    options.venueId = options.venueId || '';
    options.subdomain = options.subdomain || 'vrtp';
    options.apidomain = options.apidomain || 'te2.biz';
    options.apiuser = options.apiuser || '';
    options.apipass = options.apipass || '';
    options.rideTypes = options.rideTypes || ['Ride', 'Coasters', 'Family', 'ThrillRides', 'Kids', 'Rides & Attractions'];
    options.diningTypes = options.diningTypes || ['Snacks', 'wpDining', 'Meals', 'Dining'];
    options.showTypes = options.showTypes || ['Shows', 'Show', 'Entertainment', 'Live Entertainment', 'Presentation'];
    options.eventScheduleDays = options.eventScheduleDays || 14;

    // allow configuring credentials via TE2_* env vars
    options.configPrefixes = ['TE2'].concat(options.configPrefixes || []);

    super(options);

    if (!this.config.destinationId) throw new Error('Missing destinationId');
    if (!this.config.venueId) throw new Error('Missing venueId');
    if (!this.config.subdomain) throw new Error('Missing subdomain');
    if (!this.config.apidomain) throw new Error('Missing apidomain');
    if (!this.config.apiuser) throw new Error('Missing apiuser');
    if (!this.config.apipass) throw new Error('Missing apipass');

    this.config.apiBase = this.config.apiBase || `https://${this.config.subdomain}.${this.config.apidomain}`;

    const baseURLHostname = new URL(this.config.apiBase).hostname;

    this.http.injectForDomain({
      hostname: baseURLHostname,
    }, async (method, url, data, options = {}) => {
      const requestUrl = new URL(url);

      options.headers = {
        ...(options.headers || {}),
        'Content-Type': 'application/json',
      };

      if (requestUrl.pathname.startsWith('/rest/')) {
        const credentials = Buffer.from(`${this.config.apiuser}:${this.config.apipass}`).toString('base64');
        options.headers.Authorization = `Basic ${credentials}`;
      }
    });
  }

  /**
   * Fetch current POI status data including wait times and operational status
   * @return {Promise<object>} POI status data
   */
  async getPOIStatus() {
    '@cache|1';
    const resp = await this.http('GET', `${this.config.apiBase}/rest/venue/${this.config.venueId}/poi/all/status`);
    return resp?.body ?? resp;
  }

  /**
   * Fetch venue/destination metadata
   * @return {Promise<object>} Destination data including name and location
   */
  async getDestinationData() {
    '@cache|1440';
    const resp = await this.http('GET', `${this.config.apiBase}/rest/venue/${this.config.venueId}`);
    return resp?.body ?? resp;
  }

  /**
   * Fetch all Points of Interest (attractions, dining, shows, etc.) for this venue
   * @return {Promise<Array>} Array of POI objects
   */
  async getPOIData() {
    '@cache|1440';
    const resp = await this.http('GET', `${this.config.apiBase}/rest/venue/${this.config.venueId}/poi/all`);
    return resp?.body ?? resp;
  }

  /**
   * Fetch category definitions from the TE2 API
   * @return {Promise<object>} Category data with POI associations
   * @private
   */
  async _fetchCategories() {
    '@cache|1440';
    const resp = await this.http('GET', `${this.config.apiBase}/rest/app/${this.config.venueId}/displayCategories`);
    return resp?.body ?? resp;
  }

  /**
   * Parse category data to find POI entities matching the given types
   * Recursively includes child categories based on parent relationships
   * @param {object} params Parameters
   * @param {Array<string>} params.initialTypes Initial category types to search for
   * @return {Promise<object>} Object containing matched types and entity IDs
   * @private
   */
  async _getParsedCategories({initialTypes}) {
    const types = Array.isArray(initialTypes) ? [...initialTypes] : [];
    const entities = [];

    const categoryData = await this._fetchCategories();
    if (!Array.isArray(categoryData?.categories)) {
      return {
        types,
        entities,
      };
    }

    categoryData.categories.forEach((cat) => {
      if (types.indexOf(cat.label) >= 0) {
        if (types.indexOf(cat.id) < 0) {
          types.push(cat.id);
        }
        if (Array.isArray(cat.poi)) {
          entities.push(...cat.poi);
        }
      }
      if (cat.parent && types.indexOf(cat.parent) >= 0) {
        if (types.indexOf(cat.id) < 0) {
          types.push(cat.id);
        }
        if (Array.isArray(cat.poi)) {
          entities.push(...cat.poi);
        }
      }
    });

    return {
      types,
      entities: [...new Set(entities)],
    };
  }

  /**
   * Get parsed category data for attraction/ride types
   * @return {Promise<object>} Object with types array and entities array
   */
  async getAttractionTypes() {
    return await this._getParsedCategories({
      initialTypes: this.config.rideTypes,
    });
  }

  /**
   * Get parsed category data for dining types
   * @return {Promise<object>} Object with types array and entities array
   */
  async getDiningTypes() {
    return await this._getParsedCategories({
      initialTypes: this.config.diningTypes,
    });
  }

  /**
   * Get parsed category data for show/entertainment types
   * @return {Promise<object>} Object with types array and entities array
   */
  async getShowTypes() {
    return await this._getParsedCategories({
      initialTypes: this.config.showTypes,
    });
  }

  /**
   * Build a base entity object from TE2 API data
   * Extracts common fields like name, ID, and location
   * @param {object} data Raw entity data from TE2 API
   * @return {object} Base entity object with standardized fields
   */
  buildBaseEntityObject(data) {
    const entity = super.buildBaseEntityObject(data);

    if (data) {
      entity.name = data.name || data.label;
      entity._id = data.id || undefined;

      if (data.location) {
        if (data.location.lon !== undefined && data.location.lat !== undefined) {
          const lon = Number(data.location.lon);
          const lat = Number(data.location.lat);
          if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
            entity.location = {
              longitude: lon,
              latitude: lat,
            };
          }
        }
        if (data.location.center?.lon !== undefined && data.location.center?.lat !== undefined) {
          const lon = Number(data.location.center.lon);
          const lat = Number(data.location.center.lat);
          if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
            entity.location = {
              longitude: lon,
              latitude: lat,
            };
          }
        }
      }
    }

    return entity;
  }

  /**
   * Build the destination entity representing this venue
   * @return {Promise<object>} Destination entity object
   */
  async buildDestinationEntity() {
    const destinationData = await this.getDestinationData();
    const name = destinationData?.name || destinationData?.label || this.config.name || this.config.destinationId;
    const slug = (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '').replace(/(^-|-$)/g, '') || this.config.destinationId;
    return {
      ...this.buildBaseEntityObject(destinationData),
      _id: `${this.config.destinationId}_destination`,
      slug,
      entityType: entityType.destination,
    };
  }

  /**
   * Build park entities for this destination
   * In TE2, the destination and park are typically the same venue
   * @return {Promise<Array<object>>} Array of park entity objects
   */
  async buildParkEntities() {
    const destinationData = await this.getDestinationData();
    const name = destinationData?.name || destinationData?.label || this.config.name || this.config.destinationId;
    const slug = (name || '').toLowerCase().replace(/[^a-z0-9]+/g, '').replace(/(^-|-$)/g, '') || this.config.destinationId;
    return [
      {
        ...this.buildBaseEntityObject(destinationData),
        _id: this.config.destinationId,
        _destinationId: `${this.config.destinationId}_destination`,
        _parentId: `${this.config.destinationId}_destination`,
        slug: `${slug || this.config.destinationId}park`,
        entityType: entityType.park,
      },
    ];
  }

  /**
   * Filter POI data to get entities matching specified types/IDs
   * Supports custom filtering via includeFn callback
   * @param {object} categoryData Object containing types and entities arrays
   * @param {Array<string>} categoryData.types Category type IDs to match
   * @param {Array<string>} categoryData.entities Entity IDs to match
   * @param {object} data Additional data to merge into each entity
   * @param {object} options Options object
   * @param {Function} [options.includeFn] Custom filter function for additional inclusion logic
   * @return {Promise<Array<object>>} Array of filtered entity objects
   * @private
   */
  async _getFilteredEntities({types, entities}, data, {includeFn} = {}) {
    const poi = await this.getPOIData();
    if (!Array.isArray(poi)) {
      return [];
    }

    const typeSet = new Set(Array.isArray(types) ? types : []);
    const entitySet = new Set(Array.isArray(entities) ? entities : []);
    const seenIds = new Set();

    return poi.filter((entry) => {
      if (!entry?.id) return false;

      if (seenIds.has(entry.id)) {
        return false;
      }

      const matchesCategory = typeSet.has(entry.type) || entitySet.has(entry.id);
      const matchesFallback = typeof includeFn === 'function' ? includeFn(entry) : false;

      if (!(matchesCategory || matchesFallback)) {
        return false;
      }

      seenIds.add(entry.id);
      return true;
    }).map((entry) => {
      return {
        ...this.buildBaseEntityObject(entry),
        _destinationId: `${this.config.destinationId}_destination`,
        _parentId: this.config.destinationId,
        _parkId: this.config.destinationId,
        ...data,
      };
    });
  }

  /**
   * Build attraction entities for this destination
   * Includes rides identified by category or by presence of wait time/ride indicator tags
   * @return {Promise<Array<object>>} Array of attraction entity objects
   */
  async buildAttractionEntities() {
    return await this._getFilteredEntities(
      await this.getAttractionTypes(),
      {
        entityType: entityType.attraction,
        attractionType: attractionType.ride,
      },
      {
        includeFn: (entry) => {
          // Include entries with status data that have ride indicator tags
          const status = entry?.status;
          if (!status || (status.waitTime === undefined && status.operationalStatus === undefined)) {
            return false;
          }

          const tags = Array.isArray(entry?.tags) ? entry.tags : [];
          return tags.some((tag) => {
            const label = (tag?.label || '').toLowerCase();
            return RIDE_INDICATOR_LABELS.has(label);
          });
        },
      },
    );
  }

  /**
   * Build restaurant/dining entities for this destination
   * @return {Promise<Array<object>>} Array of restaurant entity objects
   */
  async buildRestaurantEntities() {
    return await this._getFilteredEntities(
      await this.getDiningTypes(),
      {
        entityType: entityType.restaurant,
      },
    );
  }

  /**
   * Build a POI lookup map from POI data array
   * @param {Array} poiData - Array of POI data objects
   * @return {Map} Map of POI ID to POI object
   * @private
   */
  _buildPOIMap(poiData) {
    const poiMap = new Map();
    if (Array.isArray(poiData)) {
      poiData.forEach((poi) => {
        if (poi?.id) {
          poiMap.set(poi.id, poi);
        }
      });
    }
    return poiMap;
  }

  /**
   * Find location data from associated POIs or fallback sources
   * @param {Array} associatedPois - Array of associated POI objects
   * @param {object} basePoi - Base POI object to check for location
   * @return {object|null} Location object with latitude/longitude or null
   * @private
   */
  async _findLocationForShowEntity(associatedPois, basePoi) {
    // First try to find location from associated POIs
    const poiWithLocation = associatedPois.find((poi) => poi?.location?.latitude && poi?.location?.longitude);
    if (poiWithLocation) {
      return {
        latitude: Number(poiWithLocation.location.latitude),
        longitude: Number(poiWithLocation.location.longitude),
      };
    }

    // Try base POI location
    if (basePoi?.location) {
      return {...basePoi.location};
    }

    // Fallback to config location
    if (this.config?.location) {
      return {...this.config.location};
    }

    // last resort, use destination location
    const destinationData = await this.getDestinationData();
    if (destinationData?.location?.center) {
      const lon = Number(destinationData.location.center.lon);
      const lat = Number(destinationData.location.center.lat);
      if (!Number.isNaN(lon) && !Number.isNaN(lat)) {
        return {
          latitude: lat,
          longitude: lon,
        };
      }
    }

    return null;
  }

  /**
   * Build a show entity from event calendar data
   * @param {object} event - Event data from calendar
   * @param {Map} poiMap - Map of POI IDs to POI objects
   * @return {object} Show entity object
   * @private
   */
  async _buildShowEntityFromEvent(event, poiMap) {
    const associatedPois = Array.isArray(event.associatedPois) ? event.associatedPois : [];

    // Find base POI from associated POIs
    let basePoi = null;
    for (const assoc of associatedPois) {
      if (assoc?.id && poiMap.has(assoc.id)) {
        basePoi = poiMap.get(assoc.id);
        break;
      }
    }

    // Build base entity from POI or event data
    const entityBase = basePoi
      ? this.buildBaseEntityObject(basePoi)
      : this.buildBaseEntityObject({id: event.id, name: event.title});

    const entity = {
      ...entityBase,
      _id: event.id,
      _destinationId: `${this.config.destinationId}_destination`,
      _parentId: this.config.destinationId,
      _parkId: this.config.destinationId,
      entityType: entityType.show,
      name: event.title || entityBase?.name || event.id,
    };

    // Add description if available
    if (event.description) {
      entity.description = event.description;
    }

    // Find and set location if not already set
    if (!entity.location) {
      const location = await this._findLocationForShowEntity(associatedPois, basePoi);
      if (location) {
        entity.location = location;
      }
    }

    return entity;
  }

  /**
   * Build show/entertainment entities for this destination
   * Combines show entities from categories and event calendar data
   * @return {Promise<Array<object>>} Array of show entity objects
   */
  async buildShowEntities() {
    // Get show entities from filtered show types
    const showEntities = await this._getFilteredEntities(
      await this.getShowTypes(),
      {
        entityType: entityType.show,
      },
    );

    const existingIds = new Set(showEntities.map((ent) => ent?._id).filter((id) => !!id));

    // Fetch event calendar data and add any missing show entities
    const {events, showtimesByEvent} = await this._getEventCalendarData();
    if (events.length > 0) {
      const poiData = await this.getPOIData();
      const poiMap = this._buildPOIMap(poiData);

      for (const event of events) {
        if (!event?.id) return;
        if (existingIds.has(event.id)) return;

        const entity = await this._buildShowEntityFromEvent(event, poiMap);
        showEntities.push(entity);
        existingIds.add(entity._id);
      }
    }

    return showEntities;
  }

  /**
   * Fetch venue operating hours schedule data
   * @param {object} options Options object
   * @param {number} [options.days=120] Number of days to fetch schedule for
   * @return {Promise<object>} Schedule data with daily operating hours
   * @private
   */
  async _fetchScheduleData({days = 120} = {}) {
    '@cache|1440';
    const resp = await this.http('GET', `${this.config.apiBase}/v2/venues/${this.config.venueId}/venue-hours?days=${days}`);
    return resp.body;
  }

  /**
   * Fetch event calendar data including shows and entertainment schedules
   * @param {object} options Options object
   * @param {number} [options.days] Number of days to fetch (uses config.eventScheduleDays if not specified)
   * @return {Promise<object>} Event calendar data with events and schedules
   */
  async fetchEventCalendar({days} = {}) {
    '@cache|30';
    const duration = Number.isFinite(days) ? days : this.config.eventScheduleDays;
    const resp = await this.http('GET', `${this.config.apiBase}/v2/venues/${this.config.venueId}/calendars/events?days=${duration}`);
    return resp?.body ?? resp;
  }

  /**
   * Get parsed event calendar data with showtimes organized by event
   * Filters out past events and creates showtime objects
   * @return {Promise<object>} Object containing events, eventsById map, and showtimesByEvent map
   * @private
   */
  async _getEventCalendarData() {
    try {
      const calendar = await this.fetchEventCalendar({});
      const events = Array.isArray(calendar?.events) ? calendar.events : [];
      const schedules = Array.isArray(calendar?.schedules) ? calendar.schedules : [];
      if (!events.length || !schedules.length) {
        return {
          events: [],
          eventsById: new Map(),
          showtimesByEvent: new Map(),
        };
      }

      const eventsById = new Map();
      events.forEach((event) => {
        if (event?.id) {
          eventsById.set(event.id, event);
        }
      });

      const nowMs = Date.now();
      const showtimesByEvent = new Map();

      schedules.forEach((slot) => {
        const event = eventsById.get(slot?.eventId);
        if (!event) return;

        const start = slot?.start;
        if (!start) return;

        const startMs = Date.parse(start);
        if (!Number.isFinite(startMs) || startMs < nowMs) return;

        const end = slot?.end;
        const endMs = end ? Date.parse(end) : Number.NaN;
        const showtime = {
          type: SCHEDULE_TYPE_PERFORMANCE,
          startTime: start,
          endTime: Number.isFinite(endMs) ? end : start,
        };

        if (!showtimesByEvent.has(event.id)) {
          showtimesByEvent.set(event.id, new Map());
        }

        const eventMap = showtimesByEvent.get(event.id);
        const key = `${showtime.startTime}|${showtime.endTime}`;
        if (!eventMap.has(key)) {
          eventMap.set(key, showtime);
        }
      });

      const normalizedShowtimes = new Map();
      showtimesByEvent.forEach((eventMap, eventId) => {
        const items = Array.from(eventMap.values()).sort((a, b) => a.startTime.localeCompare(b.startTime));
        if (items.length > 0) {
          normalizedShowtimes.set(eventId, items);
        }
      });

      return {
        events,
        eventsById,
        showtimesByEvent: normalizedShowtimes,
      };
    } catch (err) {
      this.log(`Failed to fetch TE2 event data: ${err?.message || err}`);
      return {
        events: [],
        eventsById: new Map(),
        showtimesByEvent: new Map(),
      };
    }
  }

  async buildEntityScheduleData() {
    const scheduleData = await this._fetchScheduleData();
    if (!Array.isArray(scheduleData?.days)) {
      return [];
    }

    const scheduleEntries = [];
    scheduleData.days.forEach((day) => {
      (day.hours || []).forEach((hours) => {
        if (day.label !== 'Park' && hours.status === 'CLOSED') return;

        const start = hours?.schedule?.start;
        const end = hours?.schedule?.end;
        if (!start || !end) return;

        const startMoment = moment(start).tz(this.config.timezone);
        const endMoment = moment(end).tz(this.config.timezone);
        if (!startMoment.isValid() || !endMoment.isValid()) return;

        const label = typeof hours.label === 'string' ? hours.label.trim() : '';
        const normalizedLabel = label.toLowerCase();

        // Determine if this is a park operating schedule or informational schedule
        let scheduleTypeValue = scheduleType.informational;
        if (PARK_SCHEDULE_LABELS.includes(normalizedLabel)) {
          scheduleTypeValue = scheduleType.operating;
        }

        scheduleEntries.push({
          date: startMoment.format('YYYY-MM-DD'),
          type: scheduleTypeValue,
          description: normalizedLabel === 'park' ? undefined : label || undefined,
          openingTime: startMoment.format(),
          closingTime: endMoment.format(),
        });
      });
    });

    if (scheduleEntries.length === 0) {
      return [];
    }

    scheduleEntries.sort((a, b) => {
      const dateCompare = a.date.localeCompare(b.date);
      if (dateCompare !== 0) {
        return dateCompare;
      }
      return a.openingTime.localeCompare(b.openingTime);
    });

    return [
      {
        _id: this.config.destinationId,
        schedule: scheduleEntries,
      },
    ];
  }

  async buildEntityLiveData() {
    const liveDataMap = new Map();

    const statusData = await this.getPOIStatus();
    if (Array.isArray(statusData)) {
      statusData.forEach((entry) => {
        if (!entry?.status || !entry.id) return;
        if (entry.id.includes('_STANDING_OFFER_BEACON')) return;

        const liveData = liveDataMap.get(entry.id) || {_id: entry.id};

        if (entry.status.waitTime !== undefined) {
          const rawWait = Number(entry.status.waitTime);
          const sanitizedWait = Number.isFinite(rawWait) ? Math.max(0, Math.round(rawWait)) : null;

          liveData.queue = {
            [queueType.standBy]: {
              waitTime: sanitizedWait,
            },
          };
        }

        liveData.status = entry.status.isOpen ? statusType.operating : statusType.closed;

        liveDataMap.set(entry.id, liveData);
      });
    }

    const {eventsById, showtimesByEvent} = await this._getEventCalendarData();
    const now = this.getTimeNowMoment();
    const todayKey = now.tz(this.config.timezone).format('YYYY-MM-DD');

    const filterShowtimesForToday = (showtimes) => {
      return showtimes.filter((slot) => {
        if (!slot?.startTime) return false;
        const startMoment = moment.tz(slot.startTime, this.config.timezone);
        if (!startMoment.isValid()) return false;
        if (startMoment.isBefore(now)) return false;
        return startMoment.format('YYYY-MM-DD') === todayKey;
      });
    };

    showtimesByEvent.forEach((showtimes, eventId) => {
      if (!showtimes.length) return;
      const todaysShowtimes = filterShowtimesForToday(showtimes);
      if (!todaysShowtimes.length) return;

      const liveData = liveDataMap.get(eventId) || {_id: eventId};
      liveData.showtimes = todaysShowtimes;
      liveData.status = statusType.operating;

      liveDataMap.set(eventId, liveData);
    });

    return Array.from(liveDataMap.values());
  }
}

export class SeaWorldGoldCoast extends TE2Destination {
  constructor(options = {}) {
    options.name = options.name || 'Sea World Gold Coast';
    options.destinationId = options.destinationId || 'vrtp_sw_te2';
    options.venueId = options.venueId || 'VRTP_SW';
    super(options);
  }
}

export class WarnerBrosMovieWorld extends TE2Destination {
  constructor(options = {}) {
    options.name = options.name || 'Warner Bros. Movie World';
    options.destinationId = options.destinationId || 'vrtp_mw_te2';
    options.venueId = options.venueId || 'VRTP_MW';
    super(options);
  }
}