/**
 * Html5PlayerAdapter — the Linux/desktop (and interim Android) PlayerAdapter
 * implementation. It owns the high-level control surface for an HTML5 `<video>`
 * element and reports the element's lifecycle back into Rust via its
 * {@link AdapterHost}.
 *
 * Design note on the split with VideoPlayer.svelte:
 * The delicate, timing-sensitive parts (hls.js instance lifecycle, the transcode
 * "reload stream" seek/audio-track dance with its dual-audio teardown and
 * canplay waits) are inherently coupled to Svelte reactive state and the DOM
 * element. Rather than relocate that reactive machinery wholesale (high
 * regression risk), the adapter receives an {@link Html5ElementBridge} of narrow
 * callbacks the owning component supplies. The adapter is the single OWNER of the
 * control contract (play/pause/seek/track/volume) and of reporting; the bridge is
 * the seam to the component's element/HLS/reactive state. This keeps all control
 * intents flowing through the PlayerAdapter interface while preserving the
 * hard-won element behavior verbatim.
 *
 * TRACES: UR-003, UR-005, UR-020, UR-021 | DR-001, DR-023, DR-024, DR-028, DR-096
 */

import type { AdapterHost, PlayerAdapter, PlayerLoadOptions } from "./types";

/**
 * Narrow seam the owning component provides so the adapter can execute the
 * element/HLS-coupled parts of a control action without re-implementing the
 * component's reactive HLS lifecycle. Every function here is a thin wrapper over
 * work the component already does.
 */
export interface Html5ElementBridge {
  /** The bound <video> element, or null before mount / after teardown. */
  getElement(): HTMLVideoElement | null;
  /** Current seek offset (seconds) for transcoded streams. */
  getSeekOffset(): number;
  setSeekOffset(offset: number): void;
  /** Update the stream URL the component renders (triggers its HLS $effect). */
  setStreamUrl(url: string): void;
  /** Tear down the component-owned hls.js instance (dual-audio prevention). */
  destroyHls(): void;
  /** Media source id for seek/audio-track URLs. */
  getMediaSourceId(): string | null;
}

/**
 * True for the `AbortError` the browser raises when a pending `play()` promise is
 * cancelled by a `pause()` (or a source/seek change). It signals "that specific
 * play attempt was superseded", not "playback failed" — hls.js' stall recovery
 * produces it routinely, so it must not reach the player's error channel.
 */
function isPlayInterruptedError(err: unknown): boolean {
  if (!err || typeof err !== "object") return false;
  const { name, message } = err as { name?: string; message?: string };
  return name === "AbortError" || (message ?? "").includes("interrupted");
}

export class Html5PlayerAdapter implements PlayerAdapter {
  readonly kind = "html5" as const;

  private attachedElement: HTMLVideoElement | null = null;
  /** In-flight play() attempt, so concurrent callers share one element.play(). */
  private pendingPlay: Promise<void> | null = null;
  private host: AdapterHost;
  private bridge: Html5ElementBridge;

  constructor(host: AdapterHost, bridge: Html5ElementBridge) {
    this.host = host;
    this.bridge = bridge;
  }

  /**
   * Resolve the LIVE <video> element. The bridge's `getElement()` returns the
   * component's current reactive `videoElement`, which is authoritative: the
   * element can be re-bound when the {#if} block re-renders, so a value captured
   * once in `attach()` may go stale (this caused play/pause to silently no-op).
   * Falls back to the attach()-captured element for unit tests whose bridge
   * returns null.
   */
  private get element(): HTMLVideoElement | null {
    return this.bridge.getElement() ?? this.attachedElement;
  }

  attach(element: HTMLVideoElement | null): void {
    this.attachedElement = element;
  }

  async load(streamUrl: string, _options: PlayerLoadOptions): Promise<void> {
    // The component's reactive HLS $effect performs the actual attach/load when
    // the stream URL is set; loading is therefore driven by setStreamUrl. The
    // component's canplay/frag-buffered path reports readiness through the host.
    this.bridge.setSeekOffset(0);
    this.bridge.setStreamUrl(streamUrl);
    this.host.onState("loading");
  }

  async play(): Promise<void> {
    const el = this.element;
    if (!el) return;
    // Coalesce concurrent attempts. While an HLS stream stalls, the UI and the
    // gap-controller recovery path can both ask to play; stacking element.play()
    // calls is what turns one stall into an AbortError storm.
    if (this.pendingPlay) return this.pendingPlay;

    this.pendingPlay = (async () => {
      try {
        await el.play();
        // handlePlay on the element reports "playing"; no double-report here.
      } catch (err) {
        // A play() aborted by a pause() is transient, not a failure: hls.js
        // nudges the element to recover from a stall, which cancels the pending
        // play promise while the element keeps trying. Surfacing it would report
        // an error roughly once a second for the duration of the stall.
        if (isPlayInterruptedError(err)) {
          console.debug("[Html5PlayerAdapter] play() interrupted by pause (stall recovery)");
        } else {
          this.host.onError(`play() failed: ${err}`);
        }
      } finally {
        this.pendingPlay = null;
      }
    })();

    return this.pendingPlay;
  }

  async pause(): Promise<void> {
    this.element?.pause();
  }

  async toggle(): Promise<boolean> {
    const el = this.element;
    if (!el) return false;
    if (el.paused) {
      await this.play();
      return true;
    }
    await this.pause();
    return false;
  }

  /**
   * PRIMITIVE: in-place element seek (no reload). The backend already decided
   * this seek does not need a transcode reload.
   */
  async seekElement(positionSeconds: number, offset: number): Promise<void> {
    const el = this.element;
    if (!el) return;
    el.currentTime = positionSeconds;
    this.bridge.setSeekOffset(offset);
    await this.waitForEvent(el, "seeked", 2000);
  }

  /**
   * PRIMITIVE: compound reload — the invariant HTML5 sequence to swap the source
   * and resume at `offset`. Contains NO strategy decision; the backend already
   * decided to reload and supplied the url/offset. Preserves the hard-won
   * dual-audio teardown and canplay wait.
   */
  async reloadSource(url: string, offset: number): Promise<void> {
    const el = this.element;
    if (!el) {
      // Still update the stream URL so the component's HLS $effect can pick it up.
      this.bridge.setSeekOffset(offset);
      this.bridge.setStreamUrl(url);
      return;
    }
    const wasPlaying = !el.paused;
    el.pause();
    this.bridge.destroyHls();
    if (el.src) {
      el.removeAttribute("src");
      el.load();
    }
    await new Promise((r) => setTimeout(r, 100));
    this.bridge.setSeekOffset(offset);
    this.bridge.setStreamUrl(url);
    await this.waitForEvent(el, "canplay", 10000);
    if (wasPlaying) await el.play();
  }

  setVolume(volume: number): void {
    if (this.element) this.element.volume = Math.max(0, Math.min(1, volume));
  }

  setMuted(muted: boolean): void {
    if (this.element) this.element.muted = muted;
  }

  /** Subtitle selection: HTML5 toggles textTracks on the element directly. */
  async selectSubtitle(streamIndex: number | null, _arrayIndex?: number): Promise<void> {
    const el = this.element;
    if (!el || !el.textTracks) return;
    for (let i = 0; i < el.textTracks.length; i++) {
      el.textTracks[i].mode = "disabled";
    }
    if (streamIndex !== null) {
      const tracks = el.querySelectorAll("track");
      tracks.forEach((track) => {
        const idx = parseInt(track.getAttribute("data-stream-index") || "-1");
        if (idx === streamIndex && track.track) {
          track.track.mode = "showing";
        }
      });
    }
  }

  getPosition(): number {
    const el = this.element;
    if (!el) return 0;
    return el.currentTime + this.bridge.getSeekOffset();
  }

  async dispose(): Promise<void> {
    this.bridge.destroyHls();
    const el = this.element;
    if (el) {
      el.pause();
      el.removeAttribute("src");
      el.load();
    }
    this.attachedElement = null;
  }

  /** Resolve when `event` fires on `el`, or after `timeoutMs` as a fallback. */
  private waitForEvent(el: HTMLVideoElement, event: string, timeoutMs: number): Promise<void> {
    return new Promise<void>((resolve) => {
      const done = () => {
        el.removeEventListener(event, done);
        resolve();
      };
      el.addEventListener(event, done);
      setTimeout(done, timeoutMs);
    });
  }
}
