import { consume, type ContextType } from "@lit/context";
import type HlsType from "hls.js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { nextRender } from "../common/util/render-status";
import { fetchStreamUrl } from "../data/camera";
import { apiContext, configContext, connectionContext } from "../data/context";
import "./ha-alert";

type HlsLite = Omit<
  HlsType,
  "subtitleTrackController" | "audioTrackController" | "emeController"
>;

const HIDDEN_CLEANUP_DELAY = 60000;

@customElement("ha-hls-player")
class HaHLSPlayer extends LitElement {
  @state()
  @consumeLocalize()
  private _localize!: LocalizeFunc;

  @state()
  @consume({ context: configContext, subscribe: true })
  private _config!: ContextType<typeof configContext>;

  @state()
  @consume({ context: apiContext, subscribe: true })
  private _api!: ContextType<typeof apiContext>;

  @state()
  @consume({ context: connectionContext, subscribe: true })
  private _connection!: ContextType<typeof connectionContext>;

  @property() public entityid?: string;

  @property() public url?: string;

  @property({ attribute: "poster-url" }) public posterUrl?: string;

  @property({ attribute: false }) public aspectRatio?: number;

  @property({ attribute: false }) public fitMode?: "cover" | "contain" | "fill";

  @property({ type: Boolean, attribute: "controls" })
  public controls = false;

  @property({ type: Boolean, attribute: "muted" })
  public muted = false;

  @property({ type: Boolean, attribute: "autoplay" })
  public autoPlay = false;

  @property({ type: Boolean, attribute: "playsinline" })
  public playsInline = false;

  @property({ type: Boolean, attribute: "allow-exoplayer" })
  public allowExoPlayer = false;

  // don't cache this, as we remove it on disconnects
  @query("video") private _videoEl!: HTMLVideoElement;

  @state() private _error?: string;

  @state() private _errorIsFatal = false;

  @state() private _url!: string;

  private _hlsPolyfillInstance?: HlsLite;

  private _exoPlayer = false;

  private static streamCount = 0;

  private _hiddenCleanupTimeout?: number;

  private _handleVisibilityChange = () => {
    if (document.pictureInPictureElement) {
      // video is playing in picture-in-picture mode, don't do anything
      return;
    }
    if (document.hidden) {
      this._hiddenCleanupTimeout = window.setTimeout(() => {
        this._hiddenCleanupTimeout = undefined;
        this._cleanUp();
      }, HIDDEN_CLEANUP_DELAY);
    } else if (this._hiddenCleanupTimeout) {
      // stream was not cleaned up yet, just cancel the cleanup
      clearTimeout(this._hiddenCleanupTimeout);
      this._hiddenCleanupTimeout = undefined;
    } else {
      this._resetError();
      this._startHls();
    }
  };

  public connectedCallback() {
    super.connectedCallback();
    HaHLSPlayer.streamCount += 1;
    if (this.hasUpdated) {
      this._resetError();
      this._startHls();
    }
    document.addEventListener("visibilitychange", this._handleVisibilityChange);
  }

  public disconnectedCallback() {
    super.disconnectedCallback();
    document.removeEventListener(
      "visibilitychange",
      this._handleVisibilityChange
    );
    clearTimeout(this._hiddenCleanupTimeout);
    this._hiddenCleanupTimeout = undefined;
    HaHLSPlayer.streamCount -= 1;
    this._cleanUp();
  }

  protected render(): TemplateResult {
    return html`
      ${
        this._error
          ? html`<ha-alert
              alert-type="error"
              class=${this._errorIsFatal ? "fatal" : "retry"}
            >
              ${this._error}
            </ha-alert>`
          : ""
      }
      ${
        !this._errorIsFatal
          ? html`<video
              .poster=${this.posterUrl}
              ?autoplay=${this.autoPlay}
              .muted=${this.muted}
              ?playsinline=${this.playsInline}
              ?controls=${this.controls}
              @loadeddata=${this._loadedData}
              style=${styleMap({
                height: this.aspectRatio == null ? "100%" : "auto",
                aspectRatio: this.aspectRatio,
                objectFit: this.fitMode,
              })}
            ></video>`
          : ""
      }
    `;
  }

  protected updated(changedProps: PropertyValues<this>) {
    super.updated(changedProps);

    const entityChanged = changedProps.has("entityid");
    const urlChanged = changedProps.has("url");

    if (entityChanged) {
      this._getStreamUrlFromEntityId();
    } else if (urlChanged && this.url) {
      this._cleanUp();
      this._resetError();
      this._url = this.url;
      this._startHls();
    }
  }

  private async _getStreamUrlFromEntityId(): Promise<void> {
    this._cleanUp();
    this._resetError();

    if (!isComponentLoaded(this._config.config, "stream")) {
      this._setFatalError("Streaming component is not loaded.");
      return;
    }

    if (!this.entityid) {
      return;
    }
    try {
      const { url } = await fetchStreamUrl(
        { callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
        this.entityid
      );

      this._url = this._connection.hassUrl(url);
      this._cleanUp();
      this._resetError();
      this._startHls();
    } catch (err: any) {
      // Fails if we were unable to get a stream
      // eslint-disable-next-line
      console.error(err);

      fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
    }
  }

  private async _startHls(): Promise<void> {
    const masterPlaylistPromise = fetch(this._url);

    // eslint-disable-next-line @typescript-eslint/naming-convention
    const Hls: typeof HlsType = (await import("hls.js/dist/hls.light.mjs"))
      .default;

    if (!this.isConnected) {
      return;
    }

    let hlsSupported = Hls.isSupported();

    if (!hlsSupported) {
      hlsSupported =
        this._videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
    }

    if (!hlsSupported) {
      this._setFatalError(
        this._localize("ui.components.media-browser.video_not_supported")
      );
      return;
    }

    const useExoPlayer =
      this.allowExoPlayer && this._config.auth.external?.config.hasExoPlayer;
    const masterPlaylist = await (await masterPlaylistPromise).text();

    if (!this.isConnected) {
      return;
    }

    // Parse playlist assuming it is a master playlist. Match group 1 and 2 are codec, match group 3 is regular playlist url
    // See https://tools.ietf.org/html/rfc8216 for HLS spec details
    const playlistRegexp =
      /#EXT-X-STREAM-INF:.*?(?:CODECS=".*?([^.]*)?\..*?,([^.]*)?\..*?".*?)?(?:\n|\r\n)(.+)/g;
    const match = playlistRegexp.exec(masterPlaylist);
    const matchTwice = playlistRegexp.exec(masterPlaylist);

    // Get the regular playlist url from the input (master) playlist, falling back to the input playlist if necessary
    // This avoids the player having to load and parse the master playlist again before loading the regular playlist
    let playlist_url: string;
    if (match !== null && matchTwice === null) {
      // Only send the regular playlist url if we match exactly once
      playlist_url = new URL(match[3], this._url).href;
    } else {
      playlist_url = this._url;
    }

    const codecs = match ? `${match[1]},${match[2]}` : undefined;

    this._reportStreams(codecs);

    // If codec is HEVC and ExoPlayer is supported, use ExoPlayer.
    if (
      useExoPlayer &&
      (codecs?.includes("hevc") || codecs?.includes("hev1"))
    ) {
      this._renderHLSExoPlayer(playlist_url);
    } else if (Hls.isSupported()) {
      this._renderHLSPolyfill(this._videoEl, Hls, playlist_url);
    } else {
      this._renderHLSNative(this._videoEl, playlist_url);
    }
  }

  private async _renderHLSExoPlayer(url: string) {
    this._exoPlayer = true;
    window.addEventListener("resize", this._resizeExoPlayer);
    this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
    this._videoEl.style.visibility = "hidden";
    await this._config.auth.external!.fireMessage({
      type: "exoplayer/play_hls",
      payload: {
        url,
        muted: this.muted,
      },
    });
  }

  private _resizeExoPlayer = () => {
    if (!this._videoEl) {
      return;
    }
    const rect = this._videoEl.getBoundingClientRect();
    this._config.auth.external!.fireMessage({
      type: "exoplayer/resize",
      payload: {
        left: rect.left,
        top: rect.top,
        right: rect.right,
        bottom: rect.bottom,
      },
    });
  };

  private _isLLHLSSupported(): boolean {
    // LL-HLS keeps multiple requests in flight, which can run into browser limitations without
    // an http/2 proxy to pipeline requests. However, a small number of streams active at
    // once should be OK.
    // The stream count may be incremented multiple times before this function is called to check
    // the count e.g. when loading a page with many streams on it. The race can work in our favor
    // so we now have a better idea on if we'll use too many browser connections later.
    if (HaHLSPlayer.streamCount <= 2) {
      return true;
    }
    if (
      !("performance" in window) ||
      performance.getEntriesByType("resource").length === 0
    ) {
      return false;
    }
    const perfEntry = performance.getEntriesByType(
      "resource"
    )[0] as PerformanceResourceTiming;
    return "nextHopProtocol" in perfEntry && perfEntry.nextHopProtocol === "h2";
  }

  private async _renderHLSPolyfill(
    videoEl: HTMLVideoElement,
    Hls: typeof HlsType,
    url: string
  ) {
    const hls = new Hls({
      backBufferLength: 60,
      fragLoadingTimeOut: 30000,
      manifestLoadingTimeOut: 30000,
      levelLoadingTimeOut: 30000,
      maxLiveSyncPlaybackRate: 2,
      lowLatencyMode: this._isLLHLSSupported(),
    });
    this._hlsPolyfillInstance = hls;
    hls.attachMedia(videoEl);
    hls.on(Hls.Events.MEDIA_ATTACHED, () => {
      this._resetError();
      hls.loadSource(url);
    });
    hls.on(Hls.Events.FRAG_LOADED, (_event, _data: any) => {
      this._resetError();
    });
    hls.on(Hls.Events.ERROR, (_event, data: any) => {
      // Some errors are recovered automatically by the hls player itself, and the others handled
      // in this function require special actions to recover. Errors retried in this function
      // are done with backoff to not cause unnecessary failures.
      if (!data.fatal) {
        return;
      }
      if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
        switch (data.details) {
          case Hls.ErrorDetails.MANIFEST_LOAD_ERROR: {
            let error = "Error starting stream, see logs for details";
            if (
              data.response !== undefined &&
              data.response.code !== undefined
            ) {
              if (data.response.code >= 500) {
                error += " (Server failure)";
              } else if (data.response.code >= 400) {
                error += " (Stream never started)";
              } else {
                error += ` (${data.response.code})`;
              }
            }
            this._setRetryableError(error);
            break;
          }
          case Hls.ErrorDetails.MANIFEST_LOAD_TIMEOUT:
            this._setRetryableError("Timeout while starting stream");
            break;
          default:
            this._setRetryableError("Stream network error");
            break;
        }
        hls.startLoad();
      } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
        this._setRetryableError("Error with media stream contents");
        hls.recoverMediaError();
      } else {
        this._setFatalError("Error playing stream");
      }
    });
  }

  private async _renderHLSNative(videoEl: HTMLVideoElement, url: string) {
    videoEl.src = url;
    videoEl.addEventListener("loadedmetadata", () => {
      videoEl.play();
    });
  }

  private _cleanUp() {
    if (this._hlsPolyfillInstance) {
      this._hlsPolyfillInstance.destroy();
      this._hlsPolyfillInstance = undefined;
    }
    if (this._exoPlayer) {
      window.removeEventListener("resize", this._resizeExoPlayer);
      this._config.auth.external!.fireMessage({ type: "exoplayer/stop" });
      this._exoPlayer = false;
    }
    if (this._videoEl) {
      this._videoEl.removeAttribute("src");
      this._videoEl.load();
    }
  }

  private _resetError() {
    this._error = undefined;
    this._errorIsFatal = false;
  }

  private _setFatalError(errorMessage: string) {
    this._error = errorMessage;
    this._errorIsFatal = true;
    fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
  }

  private _setRetryableError(errorMessage: string) {
    this._error = errorMessage;
    this._errorIsFatal = false;
    fireEvent(this, "streams", { hasAudio: false, hasVideo: false });
  }

  private _reportStreams(codecs?: string) {
    const codec = codecs?.split(",");
    fireEvent(this, "streams", {
      hasAudio: codec?.includes("mp4a") ?? false,
      hasVideo: codec?.includes("mp4a")
        ? codec?.length > 1
        : Boolean(codec?.length),
    });
  }

  private _loadedData() {
    fireEvent(this, "load");
  }

  static styles = css`
    :host,
    video {
      display: block;
    }

    video {
      width: 100%;
      max-height: var(--video-max-height, calc(100vh - 97px));
    }

    .fatal {
      display: block;
      padding: 100px 16px;
    }

    .retry {
      display: block;
    }
  `;
}

declare global {
  interface HTMLElementTagNameMap {
    "ha-hls-player": HaHLSPlayer;
  }
}
