'use strict';

/**
 * Represents a mapping of weather type names to their selected values.
 * Each key is the name of a weather type, and the value is either:
 * - a string containing the selected weather configuration name, or
 * - `null` if no selection has been made.
 *
 * @typedef {Record<string, string|null>} SelectedWeather
 */

/**
 * Callback used to dynamically calculate weather probabilities.
 *
 * @callback WeatherCallback
 * @param {Object} configs - Contextual information about the current time and weather state.
 * @param {number} configs.hour - Current in-game hour (0–23).
 * @param {number} configs.minute - Current in-game minute (0–59).
 * @param {number} configs.currentMinutes - Minutes since midnight (0–1439).
 * @param {boolean} configs.isDay - Whether it's currently daytime.
 * @param {string} configs.season - Current season.
 * @param {SelectedWeather} configs.weather - Current active weather types or nulls if none.
 * @returns {number} Probability weight for the weather type.
 */

/**
 * Represents the complete set of weather configurations.
 *
 * @typedef {Object} WeatherCfgs
 * @property {WeatherCfg} default - Default weather configuration applied when no specific condition matches.
 * @property {WeatherCfg} day - Weather configuration used during daytime hours.
 * @property {WeatherCfg} night - Weather configuration used during nighttime hours.
 * @property {Record<string, WeatherCfg>} hours - Specific weather configurations mapped by hour (e.g., `"06": WeatherCfg`).
 * @property {Record<string, WeatherCfg>} seasons - Specific weather configurations mapped by season name.
 */

/**
 * A weather configuration object mapping weather types to probability weights
 * or dynamic WeatherCallback functions.
 *
 * @typedef {Record<string, number | WeatherCallback>} WeatherCfg
 */

/**
 * Weather data with resolved numeric probability values.
 *
 * @typedef {Record<string, number>} WeatherData
 */

/**
 * Represents the data for a moon's current phase.
 *
 * @typedef {Object} MoonData
 * @property {string} name - The name of the moon.
 * @property {number} phaseIndex - The current phase index (zero-based) within the moon's cycle.
 * @property {string} phaseName - The descriptive name of the current phase.
 * @property {number} cycleLength - The total number of phases/days in the moon's full cycle.
 */

/**
 * Represents the raw data structure for a moon's cycle.
 *
 * @typedef {Object} MoonRaw
 * @property {string} name - The name of the moon.
 * @property {number} cycleLength - Total number of phases in the moon's cycle.
 * @property {number} currentPhase - The current phase index (0-based).
 * @property {string[]} [phaseNames] - Optional list of names for each phase in the cycle.
 */

/**
 * TinyDayNightCycle is a lightweight and flexible JavaScript library designed to simulate day and
 * night cycles along with seasonal changes, moons, weather patterns, and in-game time tracking.
 * It allows you to manage and customize time progression, including hours,
 * minutes, and seconds, as well as date transitions with support for variable month lengths and years.
 *
 * This system also supports dynamic weather changes, moon phases,
 * and seasonal adjustments, making it ideal for game development, simulations,
 * or any interactive experience that requires realistic or custom time and environmental cycles.
 *
 * With easy-to-use methods and configurable settings, TinyDayNightCycle provides
 * you with precise control over the flow of time and weather in your application.
 *
 * This class provides:
 * - Customizable day/night cycle with variable sunrise/sunset.
 * - Calendar system with adjustable month lengths.
 * - Dynamic weather with multiple configurable probability layers.
 * - Multi-moon phase tracking.
 *
 * Time and date calculations are independent from the real world and can run at any speed.
 */
class TinyDayNightCycle {
  /**
   * Whether to automatically adjust `daySize`, `hourSize`, and `minuteSize` when one of them changes.
   * @type {boolean}
   */
  #autoSizeAdjuste = true;

  /**
   * Number of in-game seconds representing a full day.
   * @type {number}
   */
  #daySize = 86400;

  /**
   * Number of in-game seconds representing a full hour.
   * @type {number}
   */
  #hourSize = 3600;

  /**
   * Number of in-game seconds representing a full minute.
   * @type {number}
   */
  #minuteSize = 60;

  /**
   * Stores season configurations, where each season name maps to an array of numbers.
   * The number array represent month list.
   *
   * @type {Map<string, number[]>}
   */
  #seasons = new Map();

  /**
   * Array of tracked moons with independent phases.
   * @type {MoonRaw[]}
   */
  #moons = [];

  /**
   * @type {number} Hour of the day start (0–23).
   */
  #dayStart;
  /**
   * @type {number} Hour of the night start (0–23).
   */
  #nightStart;

  /**
   * @type {SelectedWeather} Currently active weather type.
   */
  #weather = { main: null };

  /**
   * @type {number} Current time in seconds since midnight (0–86399).
   */
  #currentSeconds = 0;

  /**
   * @type {number} Current time in minutes since midnight (0–1439).
   */
  #currentMinutes = 0;

  /**
   * @type {number} Current time in hours since midnight (0–24).
   */
  #currentHours = 0;

  /**
   * @type {string} Current season.
   */
  #currentSeason = '';

  /**
   * Current day of the month.
   * @type {number}
   */
  #currentDay = 1;

  /**
   * Current month number.
   * @type {number}
   */
  #currentMonth = 1;

  /**
   * Current year count.
   * @type {number}
   */
  #currentYear = 1;

  /**
   * Whether this instance has been destroyed.
   * @type {boolean}
   */
  #isDestroyed = false;

  /**
   * Number of days in each month. Keys are month numbers.
   * Can be customized to any structure.
   * @type {number[]}
   */
  #monthDays = [31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31];

  /**
   * Weather configuration layers:
   * - `default`: Global fallback probabilities.
   * - `day` / `night`: Applied depending on time of day.
   * - `hours`: Specific time ranges (e.g. "06:00-09:00").
   * - `seasons`: Seasonal weather overrides.
   */
  #weatherConfig = {
    /**
     * General fallback probabilities
     * @type {WeatherCfg}
     */
    default: {},
    /**
     * Daytime probabilities
     * @type {WeatherCfg}
     */
    day: {},
    /**
     * Nighttime probabilities
     * @type {WeatherCfg}
     */
    night: {},
    /**
     * Specific hours { "06:00-09:00": {...} }
     * @type {Record<string, WeatherCfg>}
     */
    hours: {},
    /**
     * Seasonal configs { summer: {...}, winter: {...} }
     * @type {Record<string, WeatherCfg>}
     */
    seasons: {},
  };

  /**
   * Duration range for each weather type in minutes.
   * @type {{min: number, max: number}}
   */
  #weatherDuration = { min: 60, max: 180 }; // in minutes

  /**
   * Minutes remaining until the current weather changes.
   * @type {number}
   */
  #weatherTimeLeft = 0;

  /**
   * Gets the number of in-game seconds representing a full day.
   * @returns {number}
   */
  get daySize() {
    return this.#daySize;
  }

  /**
   * Gets whether automatic size adjustment is enabled.
   * @returns {boolean}
   */
  get autoSizeAdjuste() {
    return this.#autoSizeAdjuste;
  }

  /**
   * Sets whether automatic size adjustment is enabled.
   * @param {boolean} value - `true` to keep the proportional relation between day, hour, and minute sizes; `false` to disable auto-adjustment.
   * @throws {TypeError} If `value` is not a boolean.
   */
  set autoSizeAdjuste(value) {
    if (typeof value !== 'boolean') throw new TypeError('autoSizeAdjuste must be a boolean.');
    this.#autoSizeAdjuste = value;
  }

  /**
   * Sets the number of in-game seconds representing a full day.
   * Keeps the proportion based on the current `hourSize` and `minuteSize` if `autoSizeAdjuste` is enabled.
   * @param {number} value - Positive finite number representing seconds in a full day.
   * @throws {Error} If `value` is not a positive finite number.
   */
  set daySize(value) {
    if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
      throw new Error('daySize must be a positive finite number.');

    const hourRatio = this.#hourSize / this.#daySize;
    const minuteRatio = this.#minuteSize / this.#daySize;

    this.#daySize = value;
    if (this.#autoSizeAdjuste) {
      this.#hourSize = value * hourRatio;
      this.#minuteSize = value * minuteRatio;
    }
  }

  /**
   * Gets the number of in-game seconds representing a full hour.
   * @returns {number}
   */
  get hourSize() {
    return this.#hourSize;
  }

  /**
   * Sets the number of in-game seconds representing a full hour.
   * Keeps the proportion based on the current `daySize` and `minuteSize` if `autoSizeAdjuste` is enabled.
   * @param {number} value - Positive finite number representing seconds in a full hour.
   * @throws {Error} If `value` is not a positive finite number.
   */
  set hourSize(value) {
    if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
      throw new Error('hourSize must be a positive finite number.');

    const dayRatio = this.#daySize / this.#hourSize;
    const minuteRatio = this.#minuteSize / this.#hourSize;

    this.#hourSize = value;
    if (this.#autoSizeAdjuste) {
      this.#daySize = value * dayRatio;
      this.#minuteSize = value * minuteRatio;
    }
  }

  /**
   * Gets the number of in-game seconds representing a full minute.
   * @returns {number}
   */
  get minuteSize() {
    return this.#minuteSize;
  }

  /**
   * Sets the number of in-game seconds representing a full minute.
   * Keeps the proportion based on the current `daySize` and `hourSize` if `autoSizeAdjuste` is enabled.
   * @param {number} value - Positive finite number representing seconds in a full minute.
   * @throws {Error} If `value` is not a positive finite number.
   */
  set minuteSize(value) {
    if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
      throw new Error('minuteSize must be a positive finite number.');

    const hourRatio = this.#hourSize / this.#minuteSize;
    const dayRatio = this.#daySize / this.#minuteSize;

    this.#minuteSize = value;
    if (this.#autoSizeAdjuste) {
      this.#hourSize = value * hourRatio;
      this.#daySize = value * dayRatio;
    }
  }

  /**
   * Indicates whether this instance has been destroyed.
   * @type {boolean}
   */
  get isDestroyed() {
    return this.#isDestroyed;
  }

  /**
   * Gets the current time in seconds since midnight.
   * @returns {number} Current seconds (0 to 86399).
   */
  get currentSeconds() {
    return this.#currentSeconds;
  }

  /**
   * Sets the current time in seconds since midnight.
   * Also updates the currentMinutes property accordingly.
   * @param {number} value - Current seconds since midnight (0 to 86399).
   * @throws {TypeError} If the value is not a number.
   * @throws {RangeError} If the value is outside the valid range.
   */
  set currentSeconds(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || !Number.isFinite(value))
      throw new TypeError(`currentSeconds must be a finite number, received ${typeof value}`);
    if (value < 0 || value >= this.#daySize)
      throw new RangeError(`currentSeconds must be between 0 and 86399, received ${value}`);
    this.#currentSeconds = Math.floor(value);
    this.#currentMinutes = Math.floor(value / this.#minuteSize);
    this.#currentHours = Math.floor(value / this.#hourSize);
  }

  /**
   * Gets the current time in minutes since midnight.
   * @returns {number} Current minutes (0 to 1439).
   */
  get currentMinutes() {
    return this.#currentMinutes;
  }

  /**
   * Sets the current time in minutes since midnight.
   * Also updates the currentSeconds property accordingly (seconds set to zero).
   * @param {number} value - Current minutes since midnight (0 to 1439).
   * @throws {TypeError} If the value is not a number.
   * @throws {RangeError} If the value is outside the valid range.
   */
  set currentMinutes(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || !Number.isFinite(value))
      throw new TypeError(`currentMinutes must be a finite number, received ${typeof value}`);
    if (value < 0 || value >= 1440)
      throw new RangeError(`currentMinutes must be between 0 and 1439, received ${value}`);
    this.#currentMinutes = Math.floor(value);
    this.#currentHours = Math.floor(value / this.#minuteSize);
    this.#currentSeconds = Math.floor(value * this.#minuteSize);
  }

  /**
   * Gets the current time in hours since midnight.
   * May be a decimal value (e.g., 14.5 = 14:30).
   * @returns {number} Current hours (0 to less than 24).
   */
  get currentHours() {
    return this.#currentHours;
  }

  /**
   * Sets the current time in hours since midnight.
   * Accepts decimal numbers to specify partial hours.
   * Also updates currentMinutes and currentSeconds accordingly.
   * @param {number} value - Current hours since midnight (0 to less than 24).
   * @throws {TypeError} If the value is not a finite number.
   * @throws {RangeError} If the value is outside the valid range.
   */
  set currentHours(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || !Number.isFinite(value))
      throw new TypeError(`currentHours must be a finite number, received ${typeof value}`);
    if (value < 0 || value >= 24)
      throw new RangeError(`currentHours must be between 0 and less than 24, received ${value}`);
    this.#currentHours = Math.floor(value);
    this.#currentMinutes = Math.floor(value * this.#minuteSize);
    this.#currentSeconds = Math.floor(value * this.#hourSize);
  }

  /**
   * Returns all moons with their current phase details.
   * @returns {MoonData[]} Array of moons including their name, current phase index, phase name, and cycle length.
   */
  get moons() {
    return this.#moons.map((moon) => this.getMoon(moon));
  }

  /**
   * Returns a list of all season names currently configured.
   * @returns {string[]} Array of season names.
   */
  get seasons() {
    return Array.from(this.#seasons.keys());
  }

  /** @returns {number} Hour at which day starts (0-23). */
  get dayStart() {
    return this.#dayStart;
  }

  /** @returns {number} Hour at which night starts (0-23). */
  get nightStart() {
    return this.#nightStart;
  }

  /** @returns {SelectedWeather} Currently active weather types or nulls if none. */
  get weather() {
    return { ...this.#weather };
  }

  /** @returns {string} Currently active season name. */
  get currentSeason() {
    return this.#currentSeason;
  }

  /** @returns {number} Current day of the month. */
  get currentDay() {
    return this.#currentDay;
  }

  /** @returns {number} Current month number. */
  get currentMonth() {
    return this.#currentMonth;
  }

  /** @returns {number} Current year count. */
  get currentYear() {
    return this.#currentYear;
  }

  /**
   * Returns a shallow copy of the mapping of month numbers to days.
   * Can be customized for non-standard calendar systems.
   * @returns {number[]} Object mapping month number to days.
   */
  get monthDays() {
    return [...this.#monthDays];
  }

  /**
   * Returns the configured range of weather durations in minutes.
   * @returns {{min: number, max: number}} Object with min and max weather duration.
   */
  get weatherDuration() {
    return { ...this.#weatherDuration };
  }

  /** @returns {number} Minutes left until current weather expires. */
  get weatherTimeLeft() {
    return this.#weatherTimeLeft;
  }

  /**
   * Sets the hour at which day starts.
   * @param {number} value Hour (0-23).
   * @throws {TypeError} If value is not a number.
   */
  set dayStart(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || value < 1)
      throw new TypeError(`dayStart must be a positive non-zero number, received ${typeof value}`);
    this.#dayStart = value;
  }

  /**
   * Sets the hour at which night starts.
   * @param {number} value Hour (0-23).
   * @throws {TypeError} If value is not a number.
   */
  set nightStart(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || value < 1)
      throw new TypeError(
        `nightStart must be a positive non-zero number, received ${typeof value}`,
      );
    this.#nightStart = value;
  }

  /**
   * Sets the current weather type.
   * @param {SelectedWeather} value Weather type strings or nulls for no weather.
   * @throws {TypeError} If value is not a string or null.
   */
  set weather(value) {
    this._checkDestroyed();
    if (
      typeof value !== 'object' ||
      value === null ||
      Array.isArray(value) ||
      !Object.values(value).every((name) => typeof name === 'string')
    )
      throw new TypeError(`weather must be a object with strings, received ${typeof value}`);
    this.#weather = value;
  }

  /**
   * Sets the current season.
   * Must be one of the configured seasons.
   * @param {string} value Season name.
   * @throws {TypeError} If value is not a string or not a configured season.
   */
  set currentSeason(value) {
    this._checkDestroyed();
    if (typeof value !== 'string' || !this.#seasons.has(value)) {
      throw new TypeError(
        `currentSeason must be one of ${Array.from(this.#seasons)
          .map((v) => v[0])
          .join(', ')}, received ${value}`,
      );
    }
    this.#currentSeason = value;
  }

  /**
   * Sets the current day of the month.
   * @param {number} value Day number.
   * @throws {TypeError} If value is not a number.
   */
  set currentDay(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || value < 1 || value > this.#monthDays[this.#currentMonth - 1])
      throw new TypeError(`currentDay must be a valid day number, received ${typeof value}`);
    this.#currentDay = value;
  }

  /**
   * Sets the current month.
   * @param {number} value Month number.
   * @throws {TypeError} If value is not a number.
   */
  set currentMonth(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || typeof this.#monthDays[value - 1] !== 'number')
      throw new TypeError(`currentMonth must be a valid month number, received ${typeof value}`);
    this.#currentMonth = value;
  }

  /**
   * Sets the current year.
   * @param {number} value Year count.
   * @throws {TypeError} If value is not a number.
   */
  set currentYear(value) {
    this._checkDestroyed();
    if (typeof value !== 'number' || value < 1)
      throw new TypeError(
        `currentYear must be a positive number non-zero, received ${typeof value}`,
      );
    this.#currentYear = value;
  }

  /**
   * Sets a custom configuration for the number of days in each month.
   * This allows for non-standard calendar systems.
   * @param {number[]} value - An object where keys are month numbers and values are the number of days.
   */
  set monthDays(value) {
    this._checkDestroyed();
    if (!Array.isArray(value)) throw new TypeError(`monthDays must be a array`);
    if (!value.every((n) => typeof n === 'number'))
      throw new TypeError(`monthDays must have number values`);
    this.#monthDays = [...value];
  }

  /**
   * Sets the weather duration range in minutes.
   * @param {{min: number, max: number}} value Object with min and max durations.
   * @throws {TypeError} If value or its min/max are invalid.
   */
  set weatherDuration(value) {
    this._checkDestroyed();
    if (typeof value !== 'object' || value === null)
      throw new TypeError(`weatherDuration must be a non-null object`);
    if (typeof value.min !== 'number' || typeof value.max !== 'number')
      throw new TypeError(`weatherDuration.min and weatherDuration.max must be numbers`);
    this.#weatherDuration = { ...value };
  }

  /**
   * Sets the remaining time for current weather in minutes.
   * @param {number} value Minutes remaining.
   * @throws {TypeError} If value is not a number.
   */
  set weatherTimeLeft(value) {
    this._checkDestroyed();
    if (typeof value !== 'number')
      throw new TypeError(`weatherTimeLeft must be a number, received ${typeof value}`);
    this.#weatherTimeLeft = value;
  }

  /**
   * Returns the entire weather configuration object.
   * Includes default, day, night, hours, and seasons settings.
   * @returns {WeatherCfgs} Deep copy of the weather configuration.
   */
  get weatherConfig() {
    /** @type {Record<string, WeatherCfg>} */
    const hours = {};
    /** @type {Record<string, WeatherCfg>} */
    const seasons = {};

    for (const name in this.#weatherConfig.hours)
      hours[name] = { ...this.#weatherConfig.hours[name] };
    for (const name in this.#weatherConfig.seasons)
      seasons[name] = { ...this.#weatherConfig.seasons[name] };

    return {
      default: { ...this.#weatherConfig.default },
      day: { ...this.#weatherConfig.day },
      night: { ...this.#weatherConfig.night },
      hours,
      seasons,
    };
  }

  /**
   * Sets the entire weather configuration object.
   * Accepts default, day, night, hours, and seasons settings.
   * @param {WeatherCfgs} config - The new weather configuration.
   * @throws {TypeError} If the provided value is not a valid object or contains invalid data types.
   */
  set weatherConfig(config) {
    this._checkDestroyed();
    if (typeof config !== 'object' || config === null)
      throw new TypeError(`weatherConfig must be a non-null object, received ${config}`);

    const requiredKeys = ['default', 'day', 'night', 'hours', 'seasons'];
    for (const key of requiredKeys) {
      if (!(key in config))
        throw new TypeError(`weatherConfig is missing required property "${key}".`);
    }

    /** @type {Record<string, WeatherCfg>} */
    const hours = {};
    /** @type {Record<string, WeatherCfg>} */
    const seasons = {};

    /**
     * @param {WeatherCfg} cfg
     * @param {string} label
     */
    const validateWeatherCfg = (cfg, label) => {
      if (typeof cfg !== 'object' || cfg === null)
        throw new TypeError(`${label} must be an object, received ${cfg}`);

      for (const prop in cfg) {
        const value = cfg[prop];
        const type = typeof value;
        if (!(type === 'number' || type === 'function'))
          throw new TypeError(`${label}["${prop}"] must be a number or function, received ${type}`);
      }
    };

    for (const name in config.hours) {
      validateWeatherCfg(config.hours[name], `hours["${name}"]`);
      hours[name] = { ...config.hours[name] };
    }

    for (const name in config.seasons) {
      validateWeatherCfg(config.seasons[name], `seasons["${name}"]`);
      seasons[name] = { ...config.seasons[name] };
    }

    /**
     * @param {WeatherCfg} src
     * @param {string} label
     */
    const copyCfg = (src, label) => {
      validateWeatherCfg(src, label);
      return { ...src };
    };

    this.#weatherConfig = {
      default: copyCfg(config.default, 'default'),
      day: copyCfg(config.day, 'day'),
      night: copyCfg(config.night, 'night'),
      hours,
      seasons,
    };
  }

  /**
   * @param {number} dayStart - Hour of the day start (0-23)
   * @param {number} nightStart - Hour of the night start (0-23)
   */
  constructor(dayStart = 6, nightStart = 18) {
    this.#dayStart = dayStart;
    this.#nightStart = nightStart;
  }

  /** --------------------- SEASON SYSTEM --------------------- */

  /**
   * Adds or updates a season with the provided numeric values.
   *
   * @param {string} name - The name of the season to add or update.
   * @param {number[]} values - An array of month values associated with the season.
   * @throws {TypeError} If `name` is not a string or `values` is not an array of numbers.
   */
  addSeason(name, values) {
    this._checkDestroyed();
    if (typeof name !== 'string')
      throw new TypeError(`Season name must be a string, received ${typeof name}`);
    if (!Array.isArray(values) || !values.every((v) => typeof v === 'number'))
      throw new TypeError(`Season values must be an array of numbers`);
    this.#seasons.set(name, values);
  }

  /**
   * Removes a season from the collection.
   *
   * @param {string} name - The name of the season to remove.
   * @throws {TypeError} If `name` is not a string.
   */
  removeSeason(name) {
    this._checkDestroyed();
    if (typeof name !== 'string')
      throw new TypeError(`Season name must be a string, received ${typeof name}`);
    this.#seasons.delete(name);
    if (this.#currentSeason === name) this.#currentSeason = '';
  }

  /**
   * Checks whether a season exists in the collection.
   *
   * @param {string} name - The name of the season to check.
   * @returns {boolean} `true` if the season exists, otherwise `false`.
   * @throws {TypeError} If `name` is not a string.
   */
  hasSeason(name) {
    if (typeof name !== 'string')
      throw new TypeError(`Season name must be a string, received ${typeof name}`);
    return this.#seasons.has(name);
  }

  /**
   * Retrieves the mouth values associated with a season.
   *
   * @param {string} name - The name of the season to retrieve.
   * @returns {number[]} A copy of the month values for the specified season.
   * @throws {TypeError} If `name` is not a string.
   * @throws {Error} If the season does not exist.
   */
  getSeason(name) {
    if (typeof name !== 'string')
      throw new TypeError(`Season name must be a string, received ${typeof name}`);
    const result = this.#seasons.get(name);
    if (!result) throw new Error(`Season "${name}" not found`);
    return [...result];
  }

  /** --------------------- TIME SYSTEM --------------------- */

  /**
   * Sets the internal time directly.
   * @param {Object} settings
   * @param {number} [settings.hour=0] - 0 to 23
   * @param {number} [settings.minute=0] - 0 to 59
   * @param {number} [settings.second=0] - 0 to 59
   */
  setTime({ hour = 0, minute = 0, second = 0 }) {
    this._checkDestroyed();
    this.currentSeconds =
      (hour * this.#hourSize + minute * this.#minuteSize + second) % this.#daySize;
  }

  /**
   * Adds time to the current clock.
   * @param {Object} settings
   * @param {number} [settings.hours=0]
   * @param {number} [settings.minutes=0]
   * @param {number} [settings.seconds=0]
   */
  addTime({ hours = 0, minutes = 0, seconds = 0 }) {
    this._checkDestroyed();
    let total = this.currentSeconds + hours * this.#hourSize + minutes * this.#minuteSize + seconds;

    while (total >= this.#daySize) {
      total -= this.#daySize;
      this.nextDay();
    }
    while (total < 0) {
      total += this.#daySize;
      this.prevDay();
    }

    this.currentSeconds = total;
    this.updateWeatherTimer(
      (hours * this.#hourSize + minutes * this.#minuteSize + seconds) / this.#minuteSize,
    ); // in minutes for compatibility
  }

  /**
   * Returns the current time as both an object and a formatted string,
   * using the in-game time scale.
   *
   * @param {Object} [settings={}] - Optional configuration for time calculation.
   * @param {number} [settings.hourSize=this.#hourSize] - Number of in-game seconds representing an hour.
   * @param {number} [settings.minuteSize=this.#minuteSize] - Number of in-game seconds representing a minute.
   * @param {boolean} [settings.withSeconds=false] - Whether to include seconds in the formatted string.
   * @returns {{
   *   hour: number,
   *   minute: number,
   *   second: number,
   *   formatted: string
   * }} An object containing the hour, minute, second, and the formatted time string.
   */
  getTime({ withSeconds = false, hourSize = this.#hourSize, minuteSize = this.#minuteSize } = {}) {
    const hour = Math.floor(this.currentSeconds / hourSize);
    const minute = Math.floor((this.currentSeconds % hourSize) / minuteSize);
    const second = this.currentSeconds % minuteSize;

    return {
      hour,
      minute,
      second,
      formatted:
        `${hour.toString().padStart(2, '0')}:` +
        `${minute.toString().padStart(2, '0')}` +
        `${withSeconds ? `:${second.toString().padStart(2, '0')}` : ''}`,
    };
  }

  /**
   * Determines whether the current time is considered day.
   * Day is defined as the period between `dayStart` and `nightStart`.
   * @returns {boolean} True if current time is day, false otherwise.
   */
  isDay() {
    if (this.#dayStart < this.#nightStart) {
      return (
        this.#currentMinutes >= this.#dayStart * this.#minuteSize &&
        this.#currentMinutes < this.#nightStart * this.#minuteSize
      );
    } else {
      return (
        this.#currentMinutes >= this.#dayStart * this.#minuteSize ||
        this.#currentMinutes < this.#nightStart * this.#minuteSize
      );
    }
  }

  /**
   * Calculates the number of minutes until the next day period starts.
   * @returns {number} Minutes until day start.
   */
  minutesUntilDay() {
    return this.timeUntil(this.#dayStart, 'minutes');
  }

  /**
   * Calculates the number of seconds until the next day period starts.
   * @returns {number} Seconds until day start.
   */
  secondsUntilDay() {
    return this.timeUntil(this.#dayStart, 'seconds');
  }

  /**
   * Calculates the number of hours until the next day period starts.
   * @returns {number} Hours until day start.
   */
  hoursUntilDay() {
    return this.timeUntil(this.#dayStart, 'hours');
  }

  /**
   * Calculates the number of minutes until the next night period starts.
   * @returns {number} Minutes until night start.
   */
  minutesUntilNight() {
    return this.timeUntil(this.#nightStart, 'minutes');
  }

  /**
   * Calculates the number of seconds until the next night period starts.
   * @returns {number} Seconds until night start.
   */
  secondsUntilNight() {
    return this.timeUntil(this.#nightStart, 'seconds');
  }

  /**
   * Calculates the number of hours until the next night period starts.
   * @returns {number} Hours until night start.
   */
  hoursUntilNight() {
    return this.timeUntil(this.#nightStart, 'hours');
  }

  /**
   * Helper method to calculate time until a specified hour.
   * Works in seconds internally for higher precision.
   * Wraps around if target hour is earlier than current time.
   * Supports returning time in minutes, seconds, or hours.
   *
   * @param {number} targetHour - The target hour (0–23).
   * @param {'minutes'|'seconds'|'hours'} unit - The unit to return.
   * @returns {number} Time until target hour, in the specified unit.
   */
  timeUntil(targetHour, unit) {
    this._checkDestroyed();
    const targetSeconds = targetHour * this.#hourSize; // 1 hour = 3600 seconds
    let diffSeconds = targetSeconds - this.#currentSeconds;
    if (diffSeconds <= 0) diffSeconds += this.#daySize; // 24h = 86400 seconds (wrap to next day)

    switch (unit) {
      case 'minutes':
        return diffSeconds / this.#minuteSize;
      case 'hours':
        return diffSeconds / this.#hourSize;
      case 'seconds':
      default:
        return diffSeconds;
    }
  }

  /**
   * Instantly sets the current time to the start of the specified phase.
   * @param {"day"|"night"} phase - The phase to set the time to.
   */
  setTo(phase) {
    this._checkDestroyed();
    if (phase === 'day') this.setTime({ hour: this.#dayStart });
    else if (phase === 'night') this.setTime({ hour: this.#nightStart });
  }

  /** --------------------- DAY/MONTH/YEAR SYSTEM --------------------- */

  /**
   * Advances the current date by one day.
   * Automatically wraps months and years based on the configured month days.
   * Also updates the season and advances moon phases by 1.
   * @param {number} [amount=1] - Number of days to move forward.
   */
  nextDay(amount = 1) {
    this._checkDestroyed();
    for (let i = 0; i < amount; i++) {
      this.#currentDay++;
      const monthDays = this.#monthDays[this.#currentMonth - 1];
      if (Number.isNaN(monthDays) || !Number.isFinite(monthDays))
        throw new Error('Invalid month day count: monthDays must be a finite number.');
      if (this.#currentDay > monthDays) {
        this.#currentDay = 1;
        this.#currentMonth++;
        if (this.#currentMonth > this.#monthDays.length) {
          this.#currentMonth = 1;
          this.#currentYear++;
        }
      }
      this.updateSeason();
      this.advanceMoons(1);
    }
  }

  /**
   * Moves the current date backward by one day.
   * Automatically wraps months and years based on the configured month days.
   * Also updates the season and rewinds moon phases by 1.
   * @param {number} [amount=1] - Number of days to move backward.
   */
  prevDay(amount = 1) {
    this._checkDestroyed();
    for (let i = 0; i < amount; i++) {
      this.#currentDay--;
      if (this.#currentDay < 1) {
        this.#currentMonth--;
        if (this.#currentMonth < 1) {
          this.#currentMonth = this.#monthDays.length;
          this.#currentYear--;
        }
        this.#currentDay = this.#monthDays[this.#currentMonth - 1] || 30;
      }
      this.updateSeason();
      this.rewindMoons(1);
    }
  }

  /**
   * Updates the current season based on the month.
   */
  updateSeason() {
    this._checkDestroyed();
    this.#seasons.forEach((seasonMonths, name) => {
      if (seasonMonths.includes(this.#currentMonth)) this.#currentSeason = name;
    });
  }

  /** --------------------- WEATHER SYSTEM --------------------- */

  /**
   * Sets the weather configuration.
   * @param {WeatherCfgs} config
   * An object defining default probabilities, time-based probabilities, day/night differences, and seasonal probabilities.
   */
  setWeatherConfig(config) {
    this._checkDestroyed();
    this.#weatherConfig = { ...this.#weatherConfig, ...config };
  }

  /**
   * Sets the minimum and maximum duration for any weather type.
   * @param {number} minMinutes - Minimum duration in minutes.
   * @param {number} maxMinutes - Maximum duration in minutes.
   */
  setWeatherDuration(minMinutes, maxMinutes) {
    this._checkDestroyed();
    this.#weatherDuration.min = minMinutes;
    this.#weatherDuration.max = maxMinutes;
  }

  /**
   * Updates the remaining time for the current weather.
   * Automatically selects a new weather type if time runs out.
   * @param {number} minutesPassed - Number of in-game minutes passed since last update.
   */
  updateWeatherTimer(minutesPassed) {
    this._checkDestroyed();
    this.#weatherTimeLeft -= minutesPassed;
    if (this.#weatherTimeLeft <= 0) {
      this.chooseNewWeather();
    }
  }

  /**
   * Forces the weather to a specific type, optionally for a given duration.
   * @param {Object} settings - Configuration object.
   * @param {string} [settings.where='main'] - The target weather zone or location key.
   * @param {string|null} settings.type - The weather type (e.g., "sunny", "rain", "storm").
   * @param {number|null} [settings.duration=null] - Duration in minutes. If null, a random duration is used.
   */
  forceWeather({ where = 'main', type, duration = null }) {
    this._checkDestroyed();
    const weather = this.#weather;
    weather[where] = type;
    this.weather = weather;
    this.#weatherTimeLeft =
      duration ?? this._randomInRange(this.#weatherDuration.min, this.#weatherDuration.max);
  }

  /**
   * Chooses a new weather type based on configured probabilities.
   * Probabilities can come from:
   * - Default settings
   * - Hour-based ranges
   * - Day/night differences
   * - Seasonal settings
   * - Custom overrides passed as parameter
   * If a value is a function, it will be executed with contextual data and must return a number.
   * @param {Object} [settings={}] - Configuration object.
   * @param {string} [settings.where='main'] - The target weather zone or location key.
   * @param {WeatherCfg} [settings.customWeather] - Optional weather probability overrides.
   * @returns {string|null} - The selected weather type, or null if none selected.
   */
  chooseNewWeather({ customWeather, where = 'main' } = {}) {
    this._checkDestroyed();
    /** @type {WeatherData} */
    let probabilities = {};

    /**
     * Helper: Add probability or initialize
     * @param {WeatherCfg} source
     */
    const addProbabilities = (source) => {
      for (const [key, value] of Object.entries(source)) {
        let resolvedValue = value;

        // If it's a function, call it
        if (typeof resolvedValue === 'function') {
          resolvedValue = resolvedValue({
            hour: Math.floor(this.#currentMinutes / this.#minuteSize),
            minute: this.#currentMinutes % this.#minuteSize,
            currentMinutes: this.#currentMinutes,
            isDay: this.isDay(),
            season: this.#currentSeason,
            weather: this.#weather,
          });
        }

        // Only add if it's a number
        if (typeof resolvedValue === 'number' && !Number.isNaN(resolvedValue)) {
          probabilities[key] = (probabilities[key] || 0) + resolvedValue;
        }
      }
    };

    // 1. Default fallback
    addProbabilities(this.#weatherConfig.default || {});

    // 2. Specific hours
    for (const range in this.#weatherConfig.hours) {
      const [start, end] = range.split('-').map((t) => {
        const [h, m] = t.split(':').map(Number);
        return h * this.#minuteSize + (m || 0);
      });
      const current = this.#currentMinutes;
      const inRange =
        start <= end ? current >= start && current <= end : current >= start || current <= end;

      if (inRange) {
        addProbabilities(this.#weatherConfig.hours[range]);
      }
    }

    // 3. Day/Night
    if (this.isDay() && this.#weatherConfig.day) {
      addProbabilities(this.#weatherConfig.day);
    } else if (!this.isDay() && this.#weatherConfig.night) {
      addProbabilities(this.#weatherConfig.night);
    }

    // 4. Seasonal
    if (this.#weatherConfig.seasons?.[this.#currentSeason]) {
      addProbabilities(this.#weatherConfig.seasons[this.#currentSeason]);
    }

    // 5. Custom weather passed directly
    if (customWeather && typeof customWeather === 'object') {
      addProbabilities(customWeather);
    }

    // Pick random based on final probabilities
    const entries = Object.entries(probabilities).filter(([, prob]) => prob > 0);
    if (!entries.length) {
      this.#weather[where] = null;
      return null;
    }

    const total = entries.reduce((sum, [, prob]) => sum + prob, 0);
    let rand = Math.random() * total;

    for (const [type, prob] of entries) {
      if (rand < prob) {
        this.#weather[where] = type;
        this.#weatherTimeLeft = this._randomInRange(
          this.#weatherDuration.min,
          this.#weatherDuration.max,
        );
        return type;
      }
      rand -= prob;
    }

    this.#weather[where] = null;
    return null;
  }

  /**
   * Returns a random integer between min and max, inclusive.
   * @param {number} min - Minimum value.
   * @param {number} max - Maximum value.
   * @returns {number} - A random integer between min and max.
   */
  _randomInRange(min, max) {
    return Math.floor(Math.random() * (max - min + 1)) + min;
  }

  /** --------------------- MOON SYSTEM --------------------- */

  /**
   * Add a new moon to the system.
   * @param {string} name - Name of the moon.
   * @param {number} cycleLength - Number of days in cycle.
   * @param {string[]} phaseNames - Optional list of phase names.
   * @param {number} [startingPhase=0] - Initial phase.
   * @returns {number} - The moon index.
   */
  addMoon(name, cycleLength, phaseNames, startingPhase = 0) {
    this._checkDestroyed();
    const length = Math.max(1, cycleLength);
    this.#moons.push({
      name,
      cycleLength: length,
      currentPhase: ((startingPhase % length) + length) % length,
      phaseNames,
    });
    return this.#moons.length - 1;
  }

  /**
   * Remove a moon by name.
   * @param {string} name
   */
  removeMoon(name) {
    this._checkDestroyed();
    this.#moons = this.#moons.filter((m) => m.name !== name);
  }

  /**
   * Advance all moons in a number of days.
   * @param {number} days
   */
  advanceMoons(days = 1) {
    for (const index in this.#moons) this.advanceMoon(parseInt(index), days);
  }

  /**
   * Retrocede all moons in a number of days.
   * @param {number} days
   */
  rewindMoons(days = 1) {
    for (const index in this.#moons) this.rewindMoon(parseInt(index), days);
  }

  /**
   * Advances the phase of a single moon by a number of days.
   * @param {number} moonIndex - The index of the moon to advance.
   * @param {number} days - Number of days to advance. Defaults to 1.
   * @throws {RangeError} If the moonIndex is invalid.
   */
  advanceMoon(moonIndex, days = 1) {
    this._checkDestroyed();
    const moon = this.#moons[moonIndex];
    if (!moon) throw new RangeError(`No moon found at index ${moonIndex}`);
    moon.currentPhase = (moon.currentPhase + days) % moon.cycleLength;
  }

  /**
   * Rewinds the phase of a single moon by a number of days.
   * @param {number} moonIndex - The index of the moon to rewind.
   * @param {number} days - Number of days to rewind. Defaults to 1.
   * @throws {RangeError} If the moonIndex is invalid.
   */
  rewindMoon(moonIndex, days = 1) {
    this._checkDestroyed();
    const moon = this.#moons[moonIndex];
    if (!moon) throw new RangeError(`No moon found at index ${moonIndex}`);
    moon.currentPhase = (moon.currentPhase - days + moon.cycleLength) % moon.cycleLength;
  }

  /**
   * Checks if a moon exists at the specified index.
   *
   * @param {number} index - The index of the moon to check.
   * @returns {boolean} `true` if a moon exists at the given index, otherwise `false`.
   */
  moonExists(index) {
    if (!this.#moons[index]) return false;
    return true;
  }

  /**
   * Retrieves the moon with its current phase details.
   *
   * @param {number|MoonRaw} index - The moon index in the internal collection or a `MoonRaw` object.
   * @returns {MoonData} The moon including its name, current phase index, phase name, and cycle length.
   * @throws {TypeError} If `index` is neither a number nor a valid `MoonRaw` object.
   * @throws {RangeError} If `index` is a number but no moon exists at that position.
   * @throws {Error} If `index` is a `MoonRaw` object but required properties are missing or invalid.
   */
  getMoon(index) {
    let moon;

    if (typeof index === 'number') {
      moon = this.#moons[index];
      if (!moon) throw new RangeError(`No moon found at index ${index}`);
    } else if (
      index &&
      typeof index === 'object' &&
      typeof index.name === 'string' &&
      typeof index.cycleLength === 'number' &&
      typeof index.currentPhase === 'number'
    )
      moon = index;
    else
      throw new TypeError(
        `Invalid moon reference. Expected a number index or a MoonRaw object, received ${typeof index}`,
      );

    return {
      name: moon.name,
      phaseIndex: moon.currentPhase,
      phaseName: moon.phaseNames
        ? (moon.phaseNames[moon.currentPhase] ?? String(moon.currentPhase))
        : String(moon.currentPhase),
      cycleLength: moon.cycleLength,
    };
  }

  /**
   * Checks if the instance has been destroyed and throws an error if so.
   * @private
   * @throws {Error} If the instance has already been destroyed.
   */
  _checkDestroyed() {
    if (this.#isDestroyed)
      throw new Error('This instance has been destroyed and can no longer be used.');
  }

  /**
   * Destroys all stored data and marks the instance as unusable.
   * Clears all internal maps, arrays, and resets primitive values to defaults.
   * Once destroyed, any further method calls should throw an error or be ignored.
   */
  destroy() {
    if (this.#isDestroyed) return;

    // Clear collections
    this.#seasons.clear();
    this.#moons.length = 0;

    // Reset numbers
    this.#dayStart = 0;
    this.#nightStart = 0;
    this.#currentSeconds = 0;
    this.#currentMinutes = 0;
    this.#currentHours = 0;
    this.#currentDay = 1;
    this.#currentMonth = 1;
    this.#currentYear = 1;
    this.#weatherTimeLeft = 0;

    // Reset strings
    this.#currentSeason = '';

    // Reset weather
    this.#weather = { main: null };

    // Reset month days
    this.#monthDays = [];

    // Reset weather configuration
    this.#weatherConfig.default = {};
    this.#weatherConfig.day = {};
    this.#weatherConfig.night = {};
    this.#weatherConfig.hours = {};
    this.#weatherConfig.seasons = {};

    // Reset duration range
    this.#weatherDuration = { min: 0, max: 0 };

    // Mark as destroyed
    this.#isDestroyed = true;
  }
}

module.exports = TinyDayNightCycle;
