import type { IMediaElement } from "../../../compat/browser_compatibility_types";
import { onEnded, onSeeked, onSeeking } from "../../../compat/event_listeners";
import onHeightWidthChange from "../../../compat/on_height_width_change";
import config from "../../../config";
import log from "../../../log";
import type {
  ISupportedTextTrackFormat,
  ITextTrackSegmentData,
} from "../../../transports";
import type { IRange } from "../../../utils/ranges";
import { convertToRanges } from "../../../utils/ranges";
import type { CancellationSignal } from "../../../utils/task_canceller";
import TaskCanceller from "../../../utils/task_canceller";
import ManualTimeRanges from "../manual_time_ranges";
import type { ITextDisplayer, ITextDisplayerData } from "../types";
import parseTextTrackToElements from "./html_parsers";
import TextTrackCuesStore from "./text_track_cues_store";
import updateProportionalElements from "./update_proportional_elements";

/**
 * @param {Element} element
 * @param {Element} child
 */
function safelyRemoveChild(element: Element, child: Element) {
  try {
    element.removeChild(child);
  } catch (_error) {
    log.warn("text", "Can't remove text track: not in the element.");
  }
}

/**
 * @param {HTMLElement} element
 * @returns {Object|null}
 */
function getElementResolution(
  element: HTMLElement,
): { rows: number; columns: number } | null {
  const strRows = element.getAttribute("data-resolution-rows");
  const strColumns = element.getAttribute("data-resolution-columns");
  if (strRows === null || strColumns === null) {
    return null;
  }
  const rows = parseInt(strRows, 10);
  const columns = parseInt(strColumns, 10);
  if (rows === null || columns === null) {
    return null;
  }
  return { rows, columns };
}

/**
 * TextDisplayer implementation which display buffered TextTracks in the given
 * HTML element.
 * @class HTMLTextDisplayer
 */
export default class HTMLTextDisplayer implements ITextDisplayer {
  /**
   * The video element the cues refer to.
   * Used to know when the user is seeking, for example.
   */
  private readonly _videoElement: IMediaElement;

  /** Allows to cancel the interval at which subtitles are updated. */
  private _subtitlesIntervalCanceller: TaskCanceller;

  /** HTMLElement which will contain the cues */
  private readonly _textTrackElement: HTMLElement;

  /** Buffer containing the data */
  private readonly _buffer: TextTrackCuesStore;

  /**
   * We could need us to automatically update styling depending on
   * `_textTrackElement`'s size. This TaskCanceller allows to stop that
   * regular check.
   */
  private _sizeUpdateCanceller: TaskCanceller;

  /** Information on cues currently displayed. */
  private _currentCues: Array<{
    /** The HTMLElement containing the cues, appended to `_textTrackElement`. */
    element: HTMLElement;
    /**
     * Announced resolution for this element.
     * Necessary to properly render proportional sizes.
     */
    resolution: { columns: number; rows: number } | null;
  }>;

  /** TimeRanges implementation for this buffer. */
  private _buffered: ManualTimeRanges;

  /**
   * If `true`, we're currently automatically refreshing subtitles in intervals
   * (and on some playback events) based on the polled current position.
   *
   * TODO link it to `_subtitlesIntervalCanceller`? Or just use
   * `_subtitlesIntervalCanceller.isUsed`? To check.
   */
  private _isAutoRefreshing: boolean;

  /**
   * @param {HTMLMediaElement} videoElement
   * @param {HTMLElement} textTrackElement
   */
  constructor(videoElement: IMediaElement, textTrackElement: HTMLElement) {
    log.debug("text", "Creating HTMLTextDisplayer");
    this._buffered = new ManualTimeRanges();

    this._videoElement = videoElement;
    this._textTrackElement = textTrackElement;
    this._sizeUpdateCanceller = new TaskCanceller("HTMLTextDisplayer size updates");
    this._subtitlesIntervalCanceller = new TaskCanceller(
      "HTMLTextDisplayer subtitles updates",
    );
    this._buffer = new TextTrackCuesStore();
    this._currentCues = [];
    this._isAutoRefreshing = false;
  }

  /**
   * Push text segment to the HTMLTextDisplayer.
   * @param {Object} infos
   * @returns {Object}
   */
  public pushTextData(infos: ITextDisplayerData): IRange[] {
    log.debug("text", "Appending new html text tracks");
    const { timestampOffset, appendWindow, chunk } = infos;
    if (chunk === null) {
      return convertToRanges(this._buffered);
    }

    const {
      start: startTime,
      end: endTime,
      data: dataRaw,
      type,
      language,
      initTimescale,
    } = chunk;

    const appendWindowStart = appendWindow[0] ?? 0;
    const appendWindowEnd = appendWindow[1] ?? Infinity;

    const cues = parseTextTrackToElements(
      type,
      dataRaw,
      { initTimescale, language },
      timestampOffset,
    );

    if (appendWindowStart !== 0 && appendWindowEnd !== Infinity) {
      // Removing before window start
      let i = 0;
      while (i < cues.length && cues[i].end <= appendWindowStart) {
        i++;
      }
      cues.splice(0, i);

      i = 0;
      while (i < cues.length && cues[i].start < appendWindowStart) {
        cues[i].start = appendWindowStart;
        i++;
      }

      // Removing after window end
      i = cues.length - 1;

      while (i >= 0 && cues[i].start >= appendWindowEnd) {
        i--;
      }
      // cues[i] is the last cue we want to keep, remove from i + 1
      cues.splice(i + 1, cues.length);

      i = cues.length - 1;
      while (i >= 0 && cues[i].end > appendWindowEnd) {
        cues[i].end = appendWindowEnd;
        i--;
      }
    }

    let start: number;
    if (startTime !== undefined) {
      start = Math.max(appendWindowStart, startTime);
    } else {
      if (cues.length <= 0) {
        log.warn("text", "Current text tracks have no cues nor start time. Aborting");
        return convertToRanges(this._buffered);
      }
      log.warn("text", "No start time given. Guessing from cues.");
      start = cues[0].start;
    }

    let end: number;
    if (endTime !== undefined) {
      end = Math.min(appendWindowEnd, endTime);
    } else {
      if (cues.length <= 0) {
        log.warn("text", "Current text tracks have no cues nor end time. Aborting");
        return convertToRanges(this._buffered);
      }
      log.warn("text", "No end time given. Guessing from cues.");
      end = cues[cues.length - 1].end;
    }

    if (end <= start) {
      log.warn(
        "text",
        "Invalid text track appended: ",
        "the start time is inferior or equal to the end time.",
      );
      return convertToRanges(this._buffered);
    }
    this._buffer.insert(cues, start, end);
    this._buffered.insert(start, end);
    if (!this._isAutoRefreshing && !this._buffer.isEmpty()) {
      this.autoRefreshSubtitles(this._subtitlesIntervalCanceller.signal);
    }
    return convertToRanges(this._buffered);
  }

  /**
   * Remove buffered data.
   * @param {number} start - start position, in seconds
   * @param {number} end - end position, in seconds
   * @returns {Object}
   */
  public removeBuffer(start: number, end: number): IRange[] {
    log.debug("text", "Removing html text track data", { start, end });
    this._buffer.remove(start, end);
    this._buffered.remove(start, end);
    if (this._isAutoRefreshing && this._buffer.isEmpty()) {
      this.refreshSubtitles();
      this._isAutoRefreshing = false;
      this._subtitlesIntervalCanceller.cancel("HTMLTextDisplayer no cue");
      this._subtitlesIntervalCanceller = new TaskCanceller(
        "HTMLTextDisplayer subtitles updates",
      );
    }
    return convertToRanges(this._buffered);
  }

  /**
   * Returns the currently buffered data, in a TimeRanges object.
   * @returns {TimeRanges}
   */
  public getBufferedRanges(): IRange[] {
    return convertToRanges(this._buffered);
  }

  public reset(): void {
    log.debug("text", "Resetting HTMLTextDisplayer");
    this.stop("HTMLTextDisplayer reset");
    this._subtitlesIntervalCanceller = new TaskCanceller(
      "HTMLTextDisplayer subtitles updates",
    );
  }

  public stop(reason: string | undefined): void {
    if (this._subtitlesIntervalCanceller.isUsed()) {
      return;
    }
    log.debug("text", "Stopping HTMLTextDisplayer");
    this._disableCurrentCues(reason ?? "HTD stop");
    this._buffer.remove(0, Infinity);
    this._buffered.remove(0, Infinity);
    this._isAutoRefreshing = false;
    this._subtitlesIntervalCanceller.cancel(reason ?? "HTD stop");
  }

  /**
   * Remove the current cue from being displayed.
   * @param {string} reason - Human-inspectable reason.
   * Used for debugging matters, especially for debug log inspection.
   */
  private _disableCurrentCues(reason: string): void {
    this._sizeUpdateCanceller.cancel(reason);
    if (this._currentCues.length > 0) {
      for (const cue of this._currentCues) {
        safelyRemoveChild(this._textTrackElement, cue.element);
      }
      this._currentCues = [];
    }
  }

  /**
   * Display a new Cue. If one was already present, it will be replaced.
   * @param {HTMLElement} elements
   */
  private _displayCues(elements: HTMLElement[]): void {
    const nothingChanged =
      this._currentCues.length === elements.length &&
      this._currentCues.every((current, index) => current.element === elements[index]);

    if (nothingChanged) {
      return;
    }

    // Remove and re-display everything
    // TODO More intelligent handling

    this._sizeUpdateCanceller.cancel("HTMLTextDisplayer display");
    for (const cue of this._currentCues) {
      safelyRemoveChild(this._textTrackElement, cue.element);
    }

    this._currentCues = [];
    for (const element of elements) {
      const resolution = getElementResolution(element);
      this._currentCues.push({ element, resolution });
      this._textTrackElement.appendChild(element);
    }

    const proportionalCues = this._currentCues.filter(
      (
        cue,
      ): cue is {
        resolution: { rows: number; columns: number };
        element: HTMLElement;
      } => cue.resolution !== null,
    );

    if (proportionalCues.length > 0) {
      this._sizeUpdateCanceller = new TaskCanceller("HTMLTextDisplayer size updates");
      this._sizeUpdateCanceller.linkToSignal(this._subtitlesIntervalCanceller.signal);
      const { TEXT_TRACK_SIZE_CHECKS_INTERVAL } = config.getCurrent();
      // update propertionally-sized elements periodically
      const heightWidthRef = onHeightWidthChange(
        this._textTrackElement,
        TEXT_TRACK_SIZE_CHECKS_INTERVAL,
        this._sizeUpdateCanceller.signal,
      );
      heightWidthRef.onUpdate(
        ({ height, width }) => {
          for (const cue of proportionalCues) {
            const { resolution, element } = cue;
            updateProportionalElements(height, width, resolution, element);
          }
        },
        {
          clearSignal: this._sizeUpdateCanceller.signal,
          emitCurrentValue: true,
        },
      );
    }
  }

  /**
   * Auto-refresh the display of subtitles according to the media element's
   * position and events.
   * @param {Object} cancellationSignal
   */
  private autoRefreshSubtitles(cancellationSignal: CancellationSignal): void {
    if (this._isAutoRefreshing || cancellationSignal.isCancelled()) {
      return;
    }
    let autoRefreshCanceller: TaskCanceller | null = null;
    const { MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL } = config.getCurrent();

    const stopAutoRefresh = () => {
      this._isAutoRefreshing = false;
      if (autoRefreshCanceller !== null) {
        autoRefreshCanceller.cancel("HTMLTextDisplayer stop auto-refresh");
        autoRefreshCanceller = null;
      }
    };
    const startAutoRefresh = () => {
      stopAutoRefresh();
      this._isAutoRefreshing = true;
      autoRefreshCanceller = new TaskCanceller(
        "HTMLTextDisplayer subtitles auto-refresh",
      );
      autoRefreshCanceller.linkToSignal(cancellationSignal);
      const intervalId = setInterval(
        () => this.refreshSubtitles(),
        MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL,
      );
      autoRefreshCanceller.signal.register(() => {
        clearInterval(intervalId);
      });
      this.refreshSubtitles();
    };

    onSeeking(
      this._videoElement,
      () => {
        stopAutoRefresh();
        this._disableCurrentCues("seek");
      },
      cancellationSignal,
    );
    onSeeked(this._videoElement, startAutoRefresh, cancellationSignal);
    onEnded(this._videoElement, startAutoRefresh, cancellationSignal);

    startAutoRefresh();
  }

  /**
   * Refresh current subtitles according to the current media element's
   * position.
   */
  private refreshSubtitles(): void {
    const { MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL } = config.getCurrent();
    let time;
    if (this._videoElement.paused || this._videoElement.playbackRate <= 0) {
      time = this._videoElement.currentTime;
    } else {
      // to spread the time error, we divide the regular chosen interval.
      time = Math.max(
        this._videoElement.currentTime +
          MAXIMUM_HTML_TEXT_TRACK_UPDATE_INTERVAL / 1000 / 2,
        0,
      );
    }
    const cues = this._buffer.get(time);
    if (cues.length === 0) {
      this._disableCurrentCues("no cue");
    } else {
      this._displayCues(cues);
    }
  }
}

/** Data of chunks that should be pushed to the `HTMLTextDisplayer`. */
export interface ITextTracksBufferSegmentData<
  TDataFormatName extends ISupportedTextTrackFormat = ISupportedTextTrackFormat,
> {
  /** The text track data, in the format indicated in `type`. */
  data: TDataFormatName extends "mp4vtt" ? BufferSource : string;
  /** The format of `data` (examples: "ttml", "srt" or "vtt") */
  type: TDataFormatName;
  /**
   * Language in which the text track is, as a language code.
   * This is mostly needed for "sami" subtitles, to know which cues can / should
   * be parsed.
   */
  language?: string | undefined;
  /** start time from which the segment apply, in seconds. */
  start?: number | undefined;
  /** end time until which the segment apply, in seconds. */
  end?: number | undefined;
}

/*
 * The following ugly code is here to provide a compile-time check that an
 * `IHTMLTextTracksBufferSegmentData` (type of data pushed to a
 * `HTMLTextDisplayer`) can be derived from a `ITextTrackSegmentData`
 * (text track data parsed from a segment).
 *
 * It doesn't correspond at all to real code that will be called. This is just
 * a hack to tell TypeScript to perform that check.
 */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
if ((__ENVIRONMENT__.CURRENT_ENV as number) === (__ENVIRONMENT__.DEV as number)) {
  // @ts-expect-error: uncalled function just for type-checking
  function _checkType(input: ITextTrackSegmentData): void {
    function checkEqual(_arg: ITextTracksBufferSegmentData): void {
      /* nothing */
    }
    checkEqual(input);
  }
}
