import PlaceableObject from "./placeable-object.mjs";
import ShapeObjectMixin from "./mixins/shapes.mjs";
import AmbientSoundShapeControls from "./sounds/shape-controls.mjs";
import PreciseText from "../containers/elements/precise-text.mjs";

/**
 * An AmbientSound is an implementation of PlaceableObject which represents a dynamic audio source within the Scene.
 * @category Canvas
 * @see {@link foundry.documents.AmbientSoundDocument}
 * @see {@link foundry.canvas.layers.SoundsLayer}
 */
export default class AmbientSound extends ShapeObjectMixin(PlaceableObject) {

  /**
   * The Sound which manages playback for this AmbientSound effect
   * @type {Sound|null}
   */
  sound;

  /**
   * A sound effect attached to the managed Sound instance.
   * @type {BaseSoundEffect}
   */
  #baseEffect;

  /**
   * A  sound effect attached to the managed Sound instance when the sound source is muffled.
   * @type {BaseSoundEffect}
   */
  #muffledEffect;

  /**
   * Track whether audio effects have been initialized.
   * @type {boolean}
   */
  #effectsInitialized = false;

  /**
   * Is this AmbientSound currently muffled?
   * @type {boolean}
   */
  #muffled = false;

  /**
   * A SoundSource object which manages the area of effect for this ambient sound
   * @type {PointSoundSource}
   */
  source;

  /**
   * The area that is affected by this ambient sound.
   * @type {PIXI.Graphics}
   */
  field;

  /**
   * The shape controls.
   * @type {AmbientSoundShapeControls}
   */
  controls;

  /**
   * The tooltip text of this AmbientSound, which contains its elevation.
   * @type {PreciseText}
   */
  tooltip;

  /** @inheritdoc */
  static embeddedName = "AmbientSound";

  /** @override */
  static RENDER_FLAGS = {
    redraw: {propagate: ["refresh"]},
    refresh: {propagate: ["refreshState", "refreshTransform", "refreshField", "refreshTooltip"], alias: true},
    refreshState: {propagate: ["refreshVisibility"]},
    refreshVisibility: {},
    refreshTransform: {propagate: ["refreshPosition", "refreshSize"], alias: true},
    refreshPosition: {propagate: ["refreshMeasurements"]},
    refreshSize: {propagate: ["refreshMeasurements"]},
    refreshField: {},
    refreshTooltip: {},
    refreshMeasurements: {},
    /** @deprecated since v14 */
    refreshElevation: {propagate: ["refreshTooltip"], deprecated: {since: 14, until: 16}, alias: true}
  };

  /* -------------------------------------------- */

  /**
   * Create a Sound used to play this AmbientSound object
   * @returns {Sound|null}
   * @protected
   */
  _createSound() {
    const path = this.document.path;
    if ( !this.id || !path ) return null;
    return game.audio.create({src: path, context: game.audio.environment, singleton: true});
  }

  /* -------------------------------------------- */

  /**
   * Create special effect nodes for the Sound.
   * This only happens once the first time the AmbientSound is synced and again if the effect data changes.
   */
  #createEffects() {
    const sfx = CONFIG.soundEffects;
    const {base, muffled} = this.document.effects;
    this.#baseEffect = this.#muffledEffect = undefined;

    // Base effect
    if ( base.type in sfx ) {
      const cfg = sfx[base.type];
      this.#baseEffect = new cfg.effectClass(this.sound.context, base);
    }

    // Muffled effect
    if ( muffled.type in sfx ) {
      const cfg = sfx[muffled.type];
      this.#muffledEffect = new cfg.effectClass(this.sound.context, muffled);
    }
    this.#effectsInitialized = true;
  }

  /* -------------------------------------------- */

  /**
   * Update the set of effects which are applied to the managed Sound.
   * @param {object} [options]
   * @param {boolean} [options.muffled]     Is the sound currently muffled?
   */
  applyEffects({muffled=false}={}) {
    const effects = [];
    if ( muffled ) {
      const effect = this.#muffledEffect || this.#baseEffect;
      if ( effect ) effects.push(effect);
    }
    else if ( this.#baseEffect ) effects.push(this.#baseEffect);
    this.sound.applyEffects(effects);
  }

  /* -------------------------------------------- */
  /* Properties
  /* -------------------------------------------- */

  /** @override */
  get isInteractable() {
    if ( this.isPreview ) return false;
    if ( !this.layer.active ) return false;
    return !!ui.controls.tool?.interaction;
  }

  /* -------------------------------------------- */

  /**
   * Is this ambient sound is currently audible based on its hidden state and the darkness level of the Scene?
   * @type {boolean}
   */
  get isAudible() {
    if ( !this.document.path || this.document.hidden || !this.document.radius ) return false;
    if ( this.isFilteredOut ) return false;
    return canvas.darknessLevel.between(this.document.darkness.min ?? 0, this.document.darkness.max ?? 1);
  }

  /* -------------------------------------------- */

  /**
   * A convenience accessor for the sound radius in pixels
   * @type {number}
   */
  get radius() {
    return this.document.shape.radius;
  }

  /* -------------------------------------------- */
  /*  Methods                                     */
  /* -------------------------------------------- */

  /** @override */
  _overlapsSelection(rectangle) {
    const {x, y} = this.controls.shape.origin;
    return rectangle.contains(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Toggle playback of the sound depending on whether it is audible.
   * @param {boolean} isAudible     Is the sound audible?
   * @param {number} [volume]       The target playback volume
   * @param {object} [options={}]   Additional options which affect sound synchronization
   * @param {number} [options.fade=250]       A duration in milliseconds to fade volume transition
   * @param {boolean} [options.muffled=false] Is the sound current muffled?
   * @returns {Promise<void>}       A promise which resolves once sound playback is synchronized
   */
  async sync(isAudible, volume, {fade=250, muffled=false}={}) {

    // Discontinue playback
    if ( !isAudible ) {
      if ( !this.sound ) return;
      this.sound._manager = null;
      await this.sound.stop({volume: 0, fade});
      this.#muffled = false;
      return;
    }

    // Begin playback
    this.sound ||= this._createSound();
    if ( this.sound === null ) return;
    const sound = this.sound;

    // Track whether the AmbientSound placeable managing Sound playback has changed
    const objectChange = sound._manager !== this;
    const requireLoad = !sound.loaded && !sound._manager;
    sound._manager = this;

    // Load the buffer if necessary
    if ( requireLoad ) await sound.load();
    if ( !sound.loaded ) return;  // Some other Placeable may be loading the sound

    // Update effects
    const muffledChange = this.#muffled !== muffled;
    this.#muffled = muffled;
    if ( objectChange && !this.#effectsInitialized ) this.#createEffects();
    if ( objectChange || muffledChange ) this.applyEffects({muffled});

    // Begin playback at the desired volume
    if ( !sound.playing ) {
      const offset = sound.context.currentTime % sound.duration;
      await sound.play({volume, offset, fade, loop: true});
      return;
    }

    // Adjust volume
    await sound.fade(volume, {duration: fade});
  }

  /* -------------------------------------------- */
  /* Rendering
  /* -------------------------------------------- */

  /** @inheritdoc */
  _clear() {
    if ( this.controlIcon ) {
      this.controlIcon.parent.removeChild(this.controlIcon).destroy();
      this.controlIcon = null;
    }
    super._clear();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  async _draw(options) {
    await super._draw(options);
    this.field = this.addChild(new PIXI.Graphics());
    this.field.eventMode = "none";
    this.addChild(this._measurementLines);
    this.controls = this.addChild(new AmbientSoundShapeControls(this.document.shape.clone()));
    await this.controls.draw();
    this.addChild(this._measurementLabels);
    this.tooltip = this.addChild(this.#drawTooltip());
    this.cursor = "pointer";
    this._original?.initializeSoundSource({deleted: true});
    this.initializeSoundSource();
  }

  /* -------------------------------------------- */

  /**
   * Draw a text tooltip for the AmbientSound which can be used to display Elevation or something else.
   * @returns {PreciseText}     The text object used to render the tooltip
   */
  #drawTooltip() {
    const s = canvas.dimensions.uiScale;
    const tooltip = new PreciseText(this._getTooltipText(), this._getTextStyle());
    tooltip.anchor.set(0.5, 1);
    tooltip.pivot.set(0, CONFIG.Canvas.objectBorderThickness * 3 * s);
    tooltip.scale.set(s, s);
    return tooltip;
  }

  /* -------------------------------------------- */

  /** @override */
  _destroy(options) {
    this.initializeSoundSource({deleted: true});
    this._original?.initializeSoundSource();
  }

  /* -------------------------------------------- */
  /*  Incremental Refresh                         */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _applyRenderFlags(flags) {
    super._applyRenderFlags(flags);
    if ( flags.refreshPosition ) this._refreshPosition();
    if ( flags.refreshRotation ) this._refreshRotation();
    if ( flags.refreshSize ) this._refreshSize();
    if ( flags.refreshField ) this._refreshField();
    if ( flags.refreshTooltip ) this._refreshTooltip();
    if ( flags.refreshMeasurements ) this._refreshMeasurements();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the shape of the sound field-of-effect. This is refreshed when the SoundSource fov polygon changes.
   * @protected
   */
  _refreshField() {
    this.field.clear();
    if ( !this.source?.shape ) return;
    const s = canvas.dimensions.uiScale;
    this.field.lineStyle(2 * s, 0xEEEEEE, 0.4).beginFill(0xAADDFF, 0.15).drawShape(this.source.shape).endFill();
  }

  /* -------------------------------------------- */

  /**
   * Refresh the position of the AmbientSound. Called with the coordinates change.
   * @protected
   */
  _refreshPosition() {
    const {x, y} = this.document.shape;
    this.controls.shape.updateSource({x, y});
    this.controls.refresh();
    this.tooltip.position.set(x, y);
  }

  /* -------------------------------------------- */

  /**
   * Refresh the size of the AmbientSound.
   * @protected
   */
  _refreshSize() {
    this.controls.shape.updateSource({radius: this.document.shape.radius});
    this.controls.refresh();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _refreshState() {
    super._refreshState();
    const {hidden, locked} = this.document;
    const colors = CONFIG.Canvas.dispositionColors;
    this.controls.tint = this.controlled || this.isPreview ? colors.CONTROLLED
      : (locked ? colors.HOSTILE : colors.INACTIVE);
    this.controls.border.visible = this.controlled || this.hover || this.layer.highlightObjects || this.isPreview;
    const showNonTranslateHandles = this.controlled && (this.layer.controlled.length === 1) && !locked;
    for ( const handle of this.controls.handles.children ) {
      if ( handle.name !== "translate" ) handle.visible = showNonTranslateHandles;
    }
    this.controls.editable = this.layer.active && !!ui.controls.tool?.interaction
      && !!ui.controls.tool?.control && !locked;
    this.controls.dashed = hidden;
  }

  /* -------------------------------------------- */

  /**
   * Refresh the tooltip.
   * @protected
   */
  _refreshTooltip() {
    this.tooltip.text = this._getTooltipText();
    this.tooltip.style = this._getTextStyle();
  }

  /* -------------------------------------------- */

  /**
   * Return the text which should be displayed in the tooltip.
   * @returns {string}
   * @protected
   */
  _getTooltipText() {
    const elevation = (this.document.elevation - canvas.level.elevation.base).toNearest(0.01);
    if ( !Number.isFinite(elevation) || (elevation === 0) ) return "";
    return `${elevation.signedString()} ${canvas.grid.units}`.trim();
  }

  /* -------------------------------------------- */

  /**
   * Get the text style that should be used for the tooltip.
   * @returns {PIXI.TextStyle}
   * @protected
   */
  _getTextStyle() {
    const style = CONFIG.canvasTextStyle.clone();
    style.fontSize = 24;
    return style;
  }

  /* -------------------------------------------- */

  /** @override */
  _getMeasuredShapes() {
    return this.isPreview ? [this.controls.shape] : [];
  }

  /* -------------------------------------------- */
  /*  Sound Source Management                     */
  /* -------------------------------------------- */

  /**
   * Compute the field-of-vision for an object, determining its effective line-of-sight and field-of-vision polygons
   * @param {object} [options={}]   Options which modify how the audio source is updated
   * @param {boolean} [options.deleted]  Indicate that this SoundSource has been deleted.
   */
  initializeSoundSource({deleted=false}={}) {
    if ( this.destroyed || (this.hasPreview && !deleted) ) return;

    const wasActive = this.layer.sources.has(this.sourceId);
    const perceptionFlags = {refreshSounds: true};

    // Remove the audio source from the Scene
    if ( deleted ) {
      if ( !wasActive ) return;
      this.#destroySoundSource();
      canvas.perception.update(perceptionFlags);
      return;
    }

    // Create the sound source if necessary
    this.source ??= this.#createSoundSource();

    // Re-initialize source data and add to the active collection
    this.source.initialize(this._getSoundSourceData());
    this.source.add();

    // Schedule a perception refresh, unless that operation is deferred for some later workflow
    canvas.perception.update(perceptionFlags);
    if ( this.layer.active ) this.renderFlags.set({refreshField: true});
  }

  /* -------------------------------------------- */

  /**
   * Create a new point sound source for this AmbientSound.
   * @returns {PointSoundSource} The created source
   */
  #createSoundSource() {
    const cls = CONFIG.Canvas.soundSourceClass;
    return new cls({sourceId: this.sourceId, object: this});
  }

  /* -------------------------------------------- */

  /**
   * Destroy the point sound source for this AmbientSound.
   */
  #destroySoundSource() {
    this.source?.destroy();
    this.source = undefined;
  }

  /* -------------------------------------------- */

  /**
   * Get the sound source data.
   * @returns {BaseEffectSourceData}
   * @protected
   */
  _getSoundSourceData() {
    const {x, y, elevation, walls, levels} = this.document;
    const level = canvas.inferLevelFromElevation(elevation, {levels});
    return {
      x, y, level: level.id, elevation, radius: this.radius, walls,
      preview: this.isPreview, disabled: !this.isAudible
    };
  }

  /* -------------------------------------------- */
  /*  Document Event Handlers                     */
  /* -------------------------------------------- */

  /** @inheritDoc */
  _onCreate(data, options, userId) {
    super._onCreate(data, options, userId);
    this.initializeSoundSource();
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onUpdate(changed, options, userId) {
    super._onUpdate(changed, options, userId);

    // Change the Sound buffer
    if ( "path" in changed ) {
      if ( this.sound ) this.sound.stop();
      this.sound = this._createSound();
    }

    // Update special effects
    if ( "effects" in changed ) {
      this.#effectsInitialized = false;
      if ( this.sound?._manager === this ) this.sound._manager = null;
    }

    // Re-initialize SoundSource
    this.initializeSoundSource();

    // Incremental Refresh
    this.renderFlags.set({
      refreshState: ("hidden" in changed) || ("locked" in changed) || ("path" in changed) || ("darkness" in changed),
      refreshPosition: ("x" in changed) || ("y" in changed),
      refreshSize: "radius" in changed,
      refreshTooltip: "elevation" in changed
    });
  }

  /* -------------------------------------------- */

  /** @inheritDoc */
  _onDelete(options, userId) {
    this.sound?.stop();
    this.initializeSoundSource({deleted: true});
    super._onDelete(options, userId);
  }

  /* -------------------------------------------- */

  /** @override */
  _hasShapeChanged(changed) {
    return ["x", "y", "radius"].some(k => k in changed);
  }

  /* -------------------------------------------- */
  /*  Interactivity                               */
  /* -------------------------------------------- */

  /** @inheritdoc */
  _canHUD(user, event) {
    return this.layer.active && user.isGM; // Allow GMs to single right-click
  }

  /* -------------------------------------------- */

  /** @inheritdoc */
  _canConfigure(user, event) {
    return false; // Double-right does nothing
  }

  /* -------------------------------------------- */

  /** @override */
  _onControl(options) {
    super._onControl(options);
    for ( const object of this.layer.controlled ) {
      if ( object !== this ) object.renderFlags.set({refreshState: true});
    }
  }

  /* -------------------------------------------- */

  /** @override */
  _onRelease(options) {
    super._onRelease(options);
    for ( const object of this.layer.controlled ) object.renderFlags.set({refreshState: true});
  }

  /* -------------------------------------------- */

  /** @override */
  _onClickRight(event) {
    if ( !this.document.locked ) this.document.update({hidden: !this.document.hidden});
    if ( !this._propagateRightClick(event) ) event.stopPropagation();
  }

  /* -------------------------------------------- */

  /** @override */
  _updateDragPreviews(event) {
    const {clones, origin, shape, offset} = event.interactionData;
    const dx = shape.origin.x - (origin.x - offset.x);
    const dy = shape.origin.y - (origin.y - offset.y);
    for ( const clone of clones ) {
      const {x, y} = clone._original.document;
      clone.document.updateSource({x: x + dx, y: y + dy});
      clone.renderFlags.set({refreshPosition: true, refreshField: true});
      clone.initializeSoundSource();
    }
  }
}
