/*
 * If not stated otherwise in this file or this component's LICENSE file the
 * following copyright and licenses apply:
 *
 * Copyright 2023 Comcast Cable Communications Management, LLC.
 *
 * Licensed under the Apache License, Version 2.0 (the License);
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { type CoreNode, type CoreNodeAnimateProps } from '../CoreNode.js';
import { getTimingFunction, type TimingFunction } from '../utils.js';
import { mergeColorProgress } from '../../utils.js';
import { EventEmitter } from '../../common/EventEmitter.js';

export interface AnimationSettings {
  duration: number;
  delay: number;
  easing: string | TimingFunction;
  loop: boolean;
  repeat: number;
  stopMethod: 'reverse' | 'reset' | false;
}

type PropGroup = {
  keys: string[];
  starts: number[];
  targets: number[];
  isColor: boolean[];
  length: number;
};

type PropValuesMap = {
  props: PropGroup | null;
  shaderProps: PropGroup | null;
};

let animationIdCounter = 0;

export class CoreAnimation extends EventEmitter {
  public id: number = 0;
  public duration!: number;
  public easing!: string | TimingFunction;
  public loop!: boolean;
  public repeat!: number;
  public stopMethod!: 'reverse' | 'reset' | false;
  // Cached at init() time -- avoids re-computation in the per-frame hot path
  private hasEasing = false;
  // Reciprocal of duration -- stored so update() multiplies instead of divides
  private invDuration = 0;
  private progress = 0;
  private delayFor = 0;
  private delay = 0;
  private timingFunction!: TimingFunction;
  private node!: CoreNode;
  // Index into AnimationManager.activeAnimations -- kept in sync on every
  // register/swap-remove so unregisterAnimation() is O(1) with no indexOf scan.
  public activeIndex = -1;

  // Persistent PropGroup instances -- reused across pool recycles to avoid
  // allocating new arrays each time. length tracks how many entries are active.
  private propsGroup: PropGroup = {
    keys: [],
    starts: [],
    targets: [],
    isColor: [],
    length: 0,
  };
  private shaderPropsGroup: PropGroup = {
    keys: [],
    starts: [],
    targets: [],
    isColor: [],
    length: 0,
  };

  // Fixed set of event names this animation emits -- used for zero-alloc clearListeners()
  static readonly EVENTS = [
    'finished',
    'animating',
    'tick',
    'destroyed',
  ] as const;

  propValuesMap: PropValuesMap = { props: null, shaderProps: null };

  constructor() {
    super();
  }

  /**
   * Initialize (or reinitialize) this animation with new parameters.
   * Called both on first use and when recycled from the pool.
   */
  init(
    node: CoreNode,
    props: Partial<CoreNodeAnimateProps>,
    settings: Partial<AnimationSettings>,
  ): void {
    this.id = ++animationIdCounter;
    this.node = node;
    this.progress = 0;
    this.activeIndex = -1;
    this.propValuesMap.props = null;
    this.propValuesMap.shaderProps = null;
    // Clear any stale listeners from the previous use. With the arr.length > 0
    // guard in clearListeners(), this is a near-zero cost read of 4 array lengths
    // when unregisterAnimation() has already emptied them (the common case).
    this.clearListeners(CoreAnimation.EVENTS);

    // Reset persistent group lengths (reuse existing arrays, no new allocations)
    this.propsGroup.length = 0;
    this.shaderPropsGroup.length = 0;

    for (const key in props) {
      if (key !== 'shaderProps') {
        if (this.propValuesMap['props'] === null) {
          this.propValuesMap['props'] = this.propsGroup;
        }
        const group = this.propsGroup;
        const i = group.length++;
        group.keys[i] = key;
        group.starts[i] =
          node[key as keyof Omit<CoreNodeAnimateProps, 'shaderProps'>] || 0;
        group.targets[i] = props[
          key as keyof Omit<CoreNodeAnimateProps, 'shaderProps'>
        ] as number;
        group.isColor[i] = key.indexOf('color') !== -1;
      } else if (key === 'shaderProps' && node.shader !== null) {
        this.propValuesMap['shaderProps'] = this.shaderPropsGroup;
        const group = this.shaderPropsGroup;
        for (const key in props.shaderProps) {
          let start = node.shader.props![key];
          if (Array.isArray(start) === true) {
            start = start[0];
          }
          const i = group.length++;
          group.keys[i] = key;
          group.starts[i] = start;
          group.targets[i] = props.shaderProps[key] as number;
          group.isColor[i] = key.indexOf('color') !== -1;
        }
      }
    }

    const easing = settings.easing || 'linear';
    const delay = settings.delay ?? 0;
    const duration = settings.duration ?? 0;
    this.duration = duration;
    // Pre-compute reciprocal to replace per-frame division with multiplication
    this.invDuration = duration > 0 ? 1 / duration : 0;
    this.delay = delay;
    this.easing = easing;
    this.loop = settings.loop ?? false;
    this.repeat = settings.repeat ?? 0;
    this.stopMethod = settings.stopMethod ?? false;
    this.timingFunction =
      typeof easing === 'string' ? getTimingFunction(easing) : easing;
    // Explicit bool -- avoids string comparison on every updateValue() call
    this.hasEasing = easing !== 'linear';
    this.delayFor = delay;
  }

  reset() {
    this.progress = 0;
    this.delayFor = this.delay || 0;
    // Write start values directly rather than calling update(0), which would
    // run the full update pipeline (dirty marking, event emissions) for no gain.
    // Identical visible behaviour -- node properties snap to start values.
    const propsGroup = this.propValuesMap.props;
    const shaderGroup = this.propValuesMap.shaderProps;
    if (propsGroup !== null) {
      this.restoreValues(
        this.node as unknown as Record<string, number>,
        propsGroup,
      );
    }
    if (shaderGroup !== null) {
      this.restoreValues(
        this.node.shader!.props as Record<string, number>,
        shaderGroup,
      );
    }
  }

  private restoreValues(target: Record<string, number>, group: PropGroup) {
    const keys = group.keys;
    const starts = group.starts;
    const length = group.length;
    for (let i = 0; i < length; i++) {
      target[keys[i]!] = starts[i]!;
    }
  }

  restore() {
    // reset() already writes start values back to node properties
    this.reset();
  }

  private reverseValues(group: PropGroup) {
    const starts = group.starts;
    const targets = group.targets;
    const length = group.length;
    for (let i = 0; i < length; i++) {
      const tmp = starts[i]!;
      starts[i] = targets[i]!;
      targets[i] = tmp;
    }
  }

  reverse() {
    this.progress = 0;

    if (this.propValuesMap['props'] !== null) {
      this.reverseValues(this.propValuesMap['props']);
    }
    if (this.propValuesMap['shaderProps'] !== null) {
      this.reverseValues(this.propValuesMap['shaderProps']);
    }

    // restore stop method if we are not looping
    if (this.loop === false) {
      this.stopMethod = false;
    }
  }

  /**
   * Interpolate a single property value given the current progress.
   * progress is passed as a parameter so callers can cache it in a local,
   * avoiding repeated this.progress reads (which box floats in V8).
   */
  updateValue(
    isColor: boolean,
    propValue: number,
    startValue: number,
    progress: number,
  ): number {
    if (progress === 1) {
      return propValue;
    }
    if (progress === 0) {
      return startValue;
    }

    if (isColor === true) {
      if (startValue === propValue) {
        return startValue;
      }
      if (this.hasEasing === true) {
        const p = this.timingFunction(progress) || progress;
        return mergeColorProgress(startValue, propValue, p);
      }
      return mergeColorProgress(startValue, propValue, progress);
    }

    if (this.hasEasing === true) {
      // Inlined applyEasing: this.timingFunction(p) * (e - s) + s
      return (
        this.timingFunction(progress) * (propValue - startValue) + startValue
      );
    }
    return startValue + (propValue - startValue) * progress;
  }

  private updateValues(
    target: Record<string, number>,
    group: PropGroup,
    progress: number,
  ) {
    const keys = group.keys;
    const starts = group.starts;
    const targets = group.targets;
    const isColor = group.isColor;
    const length = group.length;
    for (let i = 0; i < length; i++) {
      target[keys[i]!] = this.updateValue(
        isColor[i]!,
        targets[i]!,
        starts[i]!,
        progress,
      );
    }
  }

  update(dt: number) {
    const { duration, loop, stopMethod } = this;
    const { delayFor } = this;

    if (this.node.destroyed) {
      this.emit('destroyed');
      return;
    }

    if (duration === 0 && delayFor === 0) {
      this.emit('finished');
      return;
    }

    if (this.delayFor > 0) {
      this.delayFor -= dt;
      if (this.delayFor >= 0) {
        // Either no or more delay left. Exit.
        return;
      } else {
        // We went beyond the delay time, add it back to dt so we can continue
        // with the animation.
        dt = -this.delayFor;
        this.delayFor = 0;
      }
    }

    if (duration === 0) {
      this.emit('finished');
      return;
    }

    // Read progress once into a local -- avoids repeated this.progress reads
    // which cause V8 to box the float value on each access to the object property.
    let progress = this.progress;

    if (progress === 0) {
      this.emit('animating');
    }

    // Multiply by pre-computed reciprocal -- avoids per-frame float division
    progress += dt * this.invDuration;

    if (progress > 1) {
      progress = loop === true ? 0 : 1;
      this.delayFor = this.delay;
      if (stopMethod !== false) {
        this.progress = progress;
        this.emit('finished');
        return;
      }
    }

    // Write back once
    this.progress = progress;

    // Extract to locals to avoid repeated property lookups in the hot path
    const propsGroup = this.propValuesMap.props;
    const shaderGroup = this.propValuesMap.shaderProps;

    if (propsGroup !== null) {
      this.updateValues(
        this.node as unknown as Record<string, number>,
        propsGroup,
        progress,
      );
    }
    if (shaderGroup !== null) {
      this.updateValues(
        this.node.shader!.props as Record<string, number>,
        shaderGroup,
        progress,
      );
    }

    if (progress < 1) {
      this.emit('tick');
    }

    if (progress === 1) {
      this.emit('finished');
    }
  }
}
