class ResilientHlsPlayer {
  constructor(video, url, options = {}) {
    this._video = video;
    // expose for DevTools debugging
    window.__video = this._video;

    this._baseUrl = url;

    this._options = Object.assign({
      checkInterval: 2000,
      addTimeStamp: false,
      debug: true,
      autoPlay: true,
      pitchResetIntervalMs: 5 * 60 * 1000,  // every 5 minutes
      enablePitchReset: true
    }, options);

    this._hls = null;
    this._isStalling = false;
    this._justRecoveredFromStall = false;

    this._isRecovering = false;
    this._isRateChanging = false;
    this._recoverStats = {};

    this._holeCount = 0;
    this._waitUntilPlayablePromise = null;

//  video.currentTime
//   └─ lastMediaTime
    this._lastMediaTime = video.currentTime;

// time / timer state
//   ├─ waitingSince
//   └─ lastPlayStartAt
//
// event timestamps (performance.now())
//   └─ lastHoleAt
//
// periodic timestamps (performance.now())
//   ├─ lastPitchResetAt
//   ├─ lastRefreshAt
//   └─ lastWallAt
//
// cooldown deadlines
//   ├─ decoderResetCooldownUntil
//   └─ cooldownUntil
    this._waitingSince = null;  // waiting start time
    this._lastPlayStartAt = null;
    this._lastHoleAt = null;

    this._lastPitchResetAt = performance.now();
    this._lastRefreshAt = performance.now();
    this._lastWallAt = performance.now();

    this._decoderResetCooldownUntil = 0;
    this._cooldownUntil = 0;

//  Date.now()
//   ├─ startedAt
//   └─ setupStartedAt
    this._startedAt = Date.now();  // playback start timestamp (for ratio calculation)
    this._setupStartedAt = Date.now();  // setup start time

    this.PITCHRESET_GRACE_MS = 1000;  // grace period after pitch reset completion to allow stabilization
    this.DECODERRESET_GRACE_MS = 3000;  // grace period after decoder reset completion to allow stabilization
    this.WAITING_GRACE_MS = 3000;  // grace period while in 'waiting' state before treating as stall
    this.STARTUP_GRACE_MS = 3000;  // grace period after playback starts to allow stabilization
    this.POST_SETUP_GRACE_MS  = 20 * 1000;  // grace period after initial setup to avoid premature error detection
    this.PLAYABLE_GRACE_MS = 15 * 1000;  // maximum time allowed to become playable before triggering recovery

    this.STARTUP_HOLE_WINDOW_MS = 2000;  // time window to detect early buffer holes during startup
    this.HOLE_HISTORY_WINDOW_MS = 5000;  // duration to retain recent hole events for analysis
    
    this.RECOVER_COOLDOWN_MS = 8000;  // minimum interval between consecutive recovery attempts
    this.MANIFEST_COOLDOWN_MS = 3000;  // minimum interval between manifest reload attempts

    this.FORCED_REFRESH_COOLDOWN_MS = 10 * 1000;  // minimum interval between forced refresh operations

    this.FATAL_LOOP_WINDOW_MS = 10 * 1000;  // time window for detecting repeated fatal error cycles
    this.FATAL_RETRY_DELAY_MS = 4000;  // delay before retrying after stream is considered unrecoverable

    this._init();
  }

  log(...args) {
    if (this._options.debug) {
      console.log("[ResilientHls]", ...args);
    }
  }

  _init() {
    this._createHls();
    this._startMonitor();
    this._startPitchResetTimer();
  }

  _isCooling(perfnow) {
    return perfnow < this._cooldownUntil;
  }

  _enterCooldown(perfnow, ms) {
    const until = perfnow + ms;
    this._cooldownUntil = Math.max(this._cooldownUntil, until);
  }

  _isDecoderResetCooling(perfnow) {
    return perfnow < this._decoderResetCooldownUntil;
  }

  _enterDecoderResetCooldown(perfnow, ms) {
    const until = perfnow + ms;
    this._decoderResetCooldownUntil = Math.max(this._decoderResetCooldownUntil, until);
  }

  _canForceRefresh(perfnow) {
    return (
      perfnow - this._lastRefreshAt >= this.FORCED_REFRESH_COOLDOWN_MS &&
      !this._isCooling(perfnow) &&
      !this._isRecovering
    );
  }

  _forcePitchReset(videoElement) {
    // refresh the audio engine by rate changes manually
    this._isRateChanging = true;

    this.log("FORCE PITCH RESET");
    videoElement.playbackRate = 1.002; 
    setTimeout(() => {
      videoElement.playbackRate = 1.0;
      setTimeout(() => {
        this._isRateChanging = false;
      }, this.PITCHRESET_GRACE_MS);
    }, 100);
  }

  async _resetDecoderState() {
    const v = this._video;
    const hls = this._hls;
    const srcUrl = this._buildUrl(); // or this._baseUrl
    const resumeTime = v.currentTime;

    this.log("FORCE DECODER RESET (hls detach/attach)");

    try {
      v.pause();
    } catch {}

    hls.detachMedia();
    v.removeAttribute('src');
    v.load();

    hls.attachMedia(v);
    hls.loadSource(srcUrl);

    try {
      await this._waitUntilPlayableStable("decoder_reset");
    } catch (e) {
      this.log("RESET failed → reload source");
    }

    try {
      v.currentTime = resumeTime;
      await v.play();
    } catch {}
  }

  async _forceDecoderReset() {
    if (this._isRecovering) return;
    this._isRecovering = true;

    try {
      await this._resetDecoderState();
    } finally {
      this._isRecovering = false;
    }
  }

  _estimateAVDrift(v) {
    if (!v) return null;

    if (v.readyState < 3 || v.buffered.length === 0) {
      return null;
    }

    try {
      const current = v.currentTime;
      const end = v.buffered.end(v.buffered.length - 1);
      const drift = end - current;

      if (!isFinite(drift) || Math.abs(drift) > 30) {
        return null;
      }
      return drift;
    } catch (e) {
      return null;
    }
  }

  _startMonitor() {
    this.timer = setInterval(() => this._check(), this._options.checkInterval);
  }

  _startPitchResetTimer() {
    if (!this._options.enablePitchReset) {
      return;
    }

    if (this.pitchTimer) {
      clearInterval(this.pitchTimer);
    }

    this.pitchTimer = setInterval(() => {
      const v = this._video;
      const current = v.currentTime;

      if (this._isRecovering) {
        this.log("Periodic pitch reset skipped: recovering");
        return;
      }

      if (v.paused || this._isRateChanging) {
        return;
      }

      const perfnow = performance.now();

      this.log(
        "Periodic pitch reset:",
        "time=" + current.toFixed(3),
        "sinceLast=" + (perfnow - this._lastPitchResetAt).toFixed(0)
      );

      this._lastPitchResetAt = perfnow;
      this._forcePitchReset(v);

    }, this._options.pitchResetIntervalMs);
  }

  _createHls() {
    clearInterval(this.timer);

    if (this._hls) {
      this._hls.detachMedia();
      this._hls.destroy();
      this._hls = null;
    }

    const url = this._buildUrl();

    this._hls = new Hls({
      enableWorker: true,
      lowLatencyMode: false
    });

    this._hls.loadSource(url);
    this._hls.attachMedia(this._video);

    // expose again to ensure latest reference
    window.__video = this._video;
    window.__hls = this._hls;

    this._bindEvents();
  }

  _buildUrl() {
    if (!this._options.addTimeStamp) return this._baseUrl;
    return this._baseUrl + (this._baseUrl.includes("?") ? "&" : "?") + "t=" + Date.now();
  }

  _bindEvents() {
    const hls = this._hls;
    const v = this._video;

    hls.on(Hls.Events.MEDIA_ATTACHED, () => {
      this.log("MEDIA_ATTACHED");
    });

    hls.on(Hls.Events.MANIFEST_PARSED, async () => {
      this.log("manifest parsed");

      try {
        await this._waitUntilPlayableStable();
      } catch (e) {
        this.log("playable timeout → continue");

        // prevent recover loop
        const perfnow = performance.now();
        this._enterCooldown(perfnow, this.MANIFEST_COOLDOWN_MS);
      }

      try {
        await this._video.play();
      } catch (e) {
        this.log("autoplay failed", e);
      }
    });

    hls.on(Hls.Events.ERROR, (event, data) => {
      this.log("HLS ERROR:", data);
      const perfnow = performance.now();

      // ===== manifest parsing fatal → stream dead =====
      if (data.fatal && data.details === 'manifestParsingError') {
        this.log("FATAL: invalid manifest → stream dead");
        this._fatalStream("manifest");
        return;
      }

      // ===== simple recover when bufferSeekOverHole us found =====
      if (data.details === Hls.ErrorDetails.BUFFER_SEEK_OVER_HOLE) {
        const isBurst = this._lastHoleAt && (perfnow - this._lastHoleAt <= this.HOLE_HISTORY_WINDOW_MS);
        this._holeCount = isBurst ? this._holeCount + 1 : 1;
        this._lastHoleAt = perfnow;

        const sincePlay = this._lastPlayStartAt == null
          ? Infinity
          : perfnow - this._lastPlayStartAt;

        // too early playing should be recovered
        if (sincePlay < this.STARTUP_HOLE_WINDOW_MS) {
          this.log("hole: early stage → force recover");
          this._recover("hole_early");
          return;
        }

        if (this._holeCount === 1) {
          this.log("hole: passthrough");
          return;
        }

        if (this._holeCount === 2) {
          this.log("hole: light recover");
          this._recover("hole_light");
          return;
        }

        if (this._holeCount >= 3 && this._canForceRefresh(perfnow)) {
          this.log("hole: escalate to force refresh");
          this._forceRefreshFlow(this._video.currentTime, perfnow);
          return;
        }
      }

      // ===== no recovery for KEY 403 =====
      if (data.details === Hls.ErrorDetails.KEY_LOAD_ERROR && data.response?.code === 403) {
        this.log("KEY 403 detected");
        return;
      }

      if (data.details === Hls.ErrorDetails.FRAG_LOAD_ERROR) {
        // ===== neither rebuild nor recover when the broken ts is found =====
        if (data.response && data.response.code === 200) {
          this.log("FATAL: broken TS detected (200 but invalid)");
          this._fatalStream("broken_ts");
          return;
        }

        if (data.fatal) { 
          this.log("FRAG LOAD ERROR (fatal) → network retry (startLoad)");
          this._hls.startLoad();
        } else {
          this.log("FRAG LOAD ERROR (non-fatal) → no action");
        }
        return;
      }

      if (data.details === Hls.ErrorDetails.BUFFER_NUDGE_ON_STALL) {
        if (!this._isDecoderResetCooling(perfnow)) {
          this.log("BUFFER_NUDGE_ON_STALL → decoder reset");
          this._enterDecoderResetCooldown(perfnow, this.DECODERRESET_GRACE_MS);
          this._forceDecoderReset();
        }
        return;
      }

      if (data.type === Hls.ErrorTypes.NETWORK_ERROR && data.details === Hls.ErrorDetails.LEVEL_LOAD_TIMEOUT) {
        this.log("LEVEL LOAD TIMEOUT");
        let buffer = Infinity;

        try {
          if (v.buffered.length > 0) {
            buffer =
              v.buffered.end(v.buffered.length - 1) -
              v.currentTime;
          }
        } catch (e) {}

        const perfnow = performance.now();

        // apparently dangerous case only
        if (buffer < 5) {
          this.log("timeout (low buffer) → force refresh");

          if (!this._canForceRefresh(perfnow)) {
            this.log("skip force refresh: cooldown");
            return;
          }

          this._forceRefreshFlow(v.currentTime, perfnow);
          return;
        }
      }

      if (data.fatal) {
        if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
          this.log("NETWORK_ERROR (fatal) → startLoad()");
          hls.startLoad();
          return;
        }
        this.log("Unknown FATAL ERROR → hls.recoverMediaError");
        this._recover("media_fatal");
      } else {
        if (data.details === Hls.ErrorDetails.BUFFER_STALLED_ERROR) {
          this.log("BUFFER_STALLED_ERROR → no action");
        }
      }
    });

    v.addEventListener("error", () => {
      this.log("VIDEO ERROR");
    });

    v.preservesPitch = true; // pitch protection for the recent browsers
    v.addEventListener("ratechange", () => {
      this.log("ratechange (no pitch reset here)");
    });

    v.addEventListener("waiting", () => {
      this.log("waiting");

      // waiting record 
      const perfnow = performance.now();
      if (this._waitingSince === null) {
        this._waitingSince = perfnow;
        this._isStalling = false;
        return;
      }

      const delta = perfnow - this._waitingSince;

      if (delta < this.WAITING_GRACE_MS) {
        return;
      }

      // over grace time → formal stalling
      this._isStalling = true;
    });

    v.addEventListener("playing", () => {
      this.log("playing");

      const perfnow = performance.now();
      this._lastPlayStartAt = perfnow;

      // ===== post-stall desync detection =====
      if (this._waitingSince !== null) {
        const stallDuration = (perfnow - this._waitingSince) / 1000;

        // stall histroy check
        const wasRealStall = this._isStalling === true;

        this._waitingSince = null;
        this._isStalling = false;

        if (
          wasRealStall &&
          stallDuration > 0.5 &&
          v.readyState === 4 &&
          v.buffered.length > 0
        ) {
          const end = v.buffered.end(v.buffered.length - 1);
          const buffer = end - v.currentTime;

          this.log(`POST-STALL: ${stallDuration.toFixed(2)}s buffer=${buffer.toFixed(2)}`);

          // set _justRecoveredFromStall value
          this._justRecoveredFromStall = true;
        } else {
          this._justRecoveredFromStall = false;
        }
      } else {
        this._justRecoveredFromStall = false;
      }

      // ===== recover trigger (NEW) =====
      if (this._justRecoveredFromStall) {
        // reset after one time evaluation
        this._justRecoveredFromStall = false;

        let drift = 0;
        if (typeof this._estimateAVDrift === "function") {
          drift = this._estimateAVDrift(v);
        }

        this.log(`[STALL RECOVER CHECK] drift=${drift}`);

        if (drift > 0.25) {
          this.log("[RECOVER] drift too large after stall → media_fatal");
          this._recover("media_fatal");
          return;
        }
      }

      // waiting record reset
      this._waitingSince = null;
      this._startedAt = Date.now();

    });
  }

  _check() {
    const v = this._video;

    if (v.paused) return;

    if (this._isRecovering) return;
    
    const now = Date.now();

    // ignore monitor immediately during cooldown
    if (this._cooldownUntil && now < this._cooldownUntil) {
      return;
    }

    // ignore monitor during a certain period after playing start
    if (now - this._startedAt < this.STARTUP_GRACE_MS) {
      return;
    }

    const perfnow = performance.now();
    const current = v.currentTime;

    if (now - this._setupStartedAt < this.POST_SETUP_GRACE_MS) {
      this._lastWallAt = perfnow;
      this._lastMediaTime = current;
      return;
    }

    const wallDelta = (perfnow - this._lastWallAt) / 1000;
    const mediaDelta = current - this._lastMediaTime;

    let ratio = 1;

    if (v.buffered.length > 0) {
      const end = v.buffered.end(v.buffered.length - 1);

      this.log(
        "STATE:",
        "time=" + current.toFixed(3),
        "buffer=" + (end - current).toFixed(3),
        "ready=" + v.readyState,
        "rate=" + v.playbackRate
      );
    }

    if (wallDelta > 0) {
      ratio = mediaDelta / wallDelta;

      this.log(`ratio: ${ratio.toFixed(3)}`);

      if (ratio < 0.8 || ratio > 1.6) {
        this.log("ratio_spike");
      }
      if (ratio < 0.85) {
        this.log("LOW SPEED DETECTED");
      }
      if (ratio === 0) {
        this.log("hard_stall");
      }
    }

    this._lastWallAt = perfnow;
    this._lastMediaTime = current;

  }

  _fatalStream(reason = "unknown") {
    this.log("STREAM DEAD → retry: ", reason);

    setTimeout(() => {
        this._recreateHls(true);
    }, this.FATAL_RETRY_DELAY_MS);
  }

  _recentFatalLoop() {
    const now = performance.now();

    // initialize
    if (!this._fatalTimestamps) {
      this._fatalTimestamps = [];
    }

    // keep the latest history only during 10 seconds
    this._fatalTimestamps = this._fatalTimestamps.filter(
      t => now - t < this.FATAL_LOOP_WINDOW_MS
    );

    // this time pushed
    this._fatalTimestamps.push(now);

    // loop detection if the count is more than 3
    return this._fatalTimestamps.length >= 3;
  }

  _waitUntilPlayableStable(mode = "normal") {
    if (this._waitUntilPlayablePromise) {
      return this._waitUntilPlayablePromise;
    }
  
    this._waitUntilPlayablePromise = new Promise((resolve, reject) => {
      const v = this._video;
      const startTime = Date.now();

      const check = () => {

        // ===== inline helper (no const outside) =====
        const isBufferedContinuously = () => {
          if (!v.buffered || v.buffered.length === 0) return false;

          const current = v.currentTime;

          for (let i = 0; i < v.buffered.length; i++) {
            const start = v.buffered.start(i);
            const end = v.buffered.end(i);

            if (current >= start + 0.2 && current <= end) {
              return true;
            }
          }
          return false;
        };

        const ready = v.readyState;
        const buffer = v.buffered.length
          ? v.buffered.end(v.buffered.length - 1) - v.currentTime
          : 0;

        const elapsed = Date.now() - startTime;

        // ---- condition per mode ----
        let minBuffer = 2;
        let minTime = 1000;

        if (mode === "recovery") {
          minBuffer = 1.5;
          minTime = 2000;
        }

        const okReady = ready >= 3;
        const okBuffer = buffer > minBuffer;
        const okTime = (elapsed > minTime) || (mode === "recovery");
        const okContinuous = isBufferedContinuously();

        this.log(
          `[START CHECK][${mode}] ready=${ready} buffer=${buffer.toFixed(
            2
          )} elapsed=${elapsed} continuous=${okContinuous}`
        );

        if (okReady && okBuffer && okTime && okContinuous) {
          this.log(`PLAY START (${mode})`);
          v.play().catch(() => {});
          this._waitUntilPlayablePromise = null;
          resolve();
          return;
        }

        // timeout safety
        if (elapsed > this.PLAYABLE_GRACE_MS) {
          this._waitUntilPlayablePromise = null;
          reject(new Error("waitUntilPlayable timeout"));
          return;
        }

        setTimeout(check, 200);
      };

      check();
    });
  }

  _forceRefreshFlow(resumeTime, perfnow) {
    if (this._isRecovering) return;

    this._lastRefreshAt = perfnow;
    this._isRecovering = true;

    this.log("FORCE REFRESH FLOW");
    this._enterCooldown(perfnow, this.RECOVER_COOLDOWN_MS);

    this._recreateHls(true);

    const v = this._video;
    (async () => {
      try {
        await this._waitUntilPlayableStable("recovery");

        if (v.buffered.length > 0) {
          const end = v.buffered.end(v.buffered.length - 1);
          const safeTime = Math.min(resumeTime, end - 0.3);
          v.currentTime = Math.max(0, safeTime);
        }
        await v.play().catch(() => {});

        v.playbackRate = 1.0;
      } catch (e) {
        this.log("ForceRefresh flow error:", e);
      } finally {
        this._isRecovering = false;
      }
    })();
  }

  _recover(reason) {
    const perfnow = performance.now();
    const v = this._video;

    if (this._isRecovering) {
      this.log("SKIP recover (already recovering)");
      return;
    }

    if (this._isCooling(perfnow)) {
      this.log("SKIP recover (cooldown)");
      return;
    }

    this._isRecovering = true;

    this._recoverStats[reason] = (this._recoverStats[reason] || 0) + 1;

    this.log("RECOVER:", reason);

    try {
      if (reason === "media_fatal" ||  reason === "hole_early") {

        this.log("MEDIA RECOVER");
        this._hls.recoverMediaError();

      } else {

        this.log("LIGHT RECOVER");

        const p = v.play();
        if (p && p.catch) p.catch(() => {});
      }

    } catch (e) {
      this.log("Recovery exception:", e);
    }

    this._isRecovering = false;
  }

  _recreateHls(fullReset = false) {
    this.log("HLS RECREATE: fullReset=", fullReset);

    clearInterval(this.timer);

    this._waitingSince = null;
    this._startedAt = Date.now();

    if (this._hls) {
      try {
        this._hls.detachMedia();
        this._hls.destroy();
      } catch (e) {}
      this._hls = null;
    }

    // video reset
    if (fullReset) {
      // full reset
      this._video.pause();
      this._video.removeAttribute("src");
      this._video.load();
    } else {
      // half reset with buffer change
      try {
        this._video.currentTime = this._video.currentTime;
      } catch (e) {}
    }

    // reset flags/counter/score 
    this._setupStartedAt = Date.now();
    this._isRecovering = false;

    this._createHls(); // create hls again
    this._startMonitor();
  }

  destroy() {
    clearInterval(this.timer);
    clearInterval(this.pitchTimer);

    if (this._hls) {
      this._hls.detachMedia();
      this._hls.destroy();
      this._hls = null;
    }
  }

  setSource(newUrl, autoPlay = true) {
    this.log("SET SOURCE:", newUrl);

    const resumeTime = this._video.currentTime;
    const wasPlaying = !this._video.paused;

    this._baseUrl = newUrl;

    try {
      this._recreateHls();

      this._waitUntilPlayableStable("recovery")
        .then(() => {
          this._video.currentTime = resumeTime;
          this._video.currentTime += 0.001;

          if (autoPlay && wasPlaying) {
            const p = this._video.play();
            if (p && p.catch) p.catch(() => {});
          }

          this.log("RESUME OK @", resumeTime);
        })
        .catch(() => {
          // fallback
          this._video.currentTime = resumeTime;

          if (autoPlay && wasPlaying) {
            const p = this._video.play();
            if (p && p.catch) p.catch(() => {});
          }

          this.log("RESUME fallback");
        });
    } catch (e) {
      this.log("setSource error:", e);
    }
  }
}

function HlsStart() {
  const urlNew = document.getElementById('m3u8URLSave').value;
  const video = document.getElementById('oVideo');

  if (!video._player) {
    video._player = new ResilientHlsPlayer(video, urlNew);
  } else {
    video._player.setSource(urlNew);
  }
}