import chai from 'chai';
import sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { State } from '../../../src/controller/base-stream-controller';
import { FragmentTracker } from '../../../src/controller/fragment-tracker';
import GapController from '../../../src/controller/gap-controller';
import { ErrorDetails, ErrorTypes } from '../../../src/errors';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import {
  BufferHelper,
  type BufferInfo,
} from '../../../src/utils/buffer-helper';
import { MockMediaElement, MockMediaSource } from '../utils/mock-media';
import type { HlsConfig } from '../../../src/config';
import type StreamController from '../../../src/controller/stream-controller';
import type { Fragment, MediaFragment } from '../../../src/loader/fragment';

chai.use(sinonChai);
const expect = chai.expect;

type GapControllerTestable = Omit<GapController, ''> & {
  fragmentTracker: FragmentTracker;
  media: HTMLMediaElement;
  moved: boolean;
  nudgeRetry: number;
  stalled: number;
  stallReported: boolean;
  waiting: number;
  _reportStall(bufferInfo: BufferInfo): void;
  _tryFixBufferStall(bufferInfo: BufferInfo, stalledDurationMs: number): void;
  _tryNudgeBuffer(bufferInfo: BufferInfo): void;
  _trySkipBufferHole(partial: Fragment | null): number;
};

describe('GapController', function () {
  let hls: Hls;
  let streamController: StreamController;
  let config: HlsConfig;
  let gapController: GapControllerTestable;
  let media: HTMLMediaElement;
  let mediaSource: MediaSource;
  let triggerSpy;
  const sandbox = sinon.createSandbox();

  beforeEach(function () {
    hls = new Hls({ debug: true });
    config = hls.config;
    const hlsTestable: any = hls;
    for (let i = hlsTestable.networkControllers.length; i--; ) {
      const component = hlsTestable.networkControllers[i];
      if (component !== (hls as any).streamController) {
        component.destroy();
        hlsTestable.networkControllers.splice(i, 1);
      }
    }
    hlsTestable.coreComponents.forEach((component) => component.destroy());
    hlsTestable.coreComponents.length = 0;
    media = new MockMediaElement() as unknown as HTMLMediaElement;
    mediaSource = new MockMediaSource() as unknown as MediaSource;
    streamController = (hls as any).streamController;
    streamController.state = State.IDLE;
    gapController = new GapController(
      hls,
      new FragmentTracker(hls as Hls),
    ) as unknown as GapControllerTestable;
    hls.trigger(Events.MEDIA_ATTACHING, { media });
    hls.trigger(Events.MEDIA_ATTACHED, {
      media,
      mediaSource,
    });
    triggerSpy = sinon.spy(hls, 'trigger');
  });

  afterEach(function () {
    sandbox.restore();
    hls.destroy();
  });

  describe('_tryNudgeBuffer', function () {
    const bufferInfo = BufferHelper.bufferedInfo([{ start: 0, end: 10 }], 0, 0);
    it('should increment the currentTime by a multiple of nudgeRetry and the configured nudge amount', function () {
      for (let i = 0; i < config.nudgeMaxRetry; i++) {
        triggerSpy.resetHistory();

        const expected = media.currentTime + (i + 1) * config.nudgeOffset;
        gapController._tryNudgeBuffer(bufferInfo);
        expect(media.currentTime).to.equal(expected);

        expect(triggerSpy).to.have.been.calledWith(Events.ERROR, {
          type: ErrorTypes.MEDIA_ERROR,
          details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
          fatal: false,
          error: triggerSpy.getCall(0).lastArg.error,
          buffer: bufferInfo.len,
          bufferInfo,
        });
      }

      triggerSpy.resetHistory();
      gapController._tryNudgeBuffer(bufferInfo);

      expect(triggerSpy).not.to.have.been.calledWith(Events.ERROR, {
        type: ErrorTypes.MEDIA_ERROR,
        details: ErrorDetails.BUFFER_NUDGE_ON_STALL,
        fatal: false,
      });
    });

    it('should not increment the currentTime if the max amount of nudges has been attempted', function () {
      config.nudgeMaxRetry = 0;
      gapController._tryNudgeBuffer(bufferInfo);
      expect(media.currentTime).to.equal(0);
      expect(triggerSpy).to.have.been.called;
      expect(triggerSpy).to.have.been.calledWith(Events.ERROR, {
        type: ErrorTypes.MEDIA_ERROR,
        details: ErrorDetails.BUFFER_STALLED_ERROR,
        fatal: true,
        error: triggerSpy.getCall(0).lastArg.error,
        buffer: bufferInfo.len,
        bufferInfo,
      });
    });
  });

  describe('_reportStall', function () {
    it('should report a stall with the current buffer length if it has not already been reported', function () {
      const bufferInfo = BufferHelper.bufferedInfo(
        [{ start: 0, end: 42 }],
        0,
        0,
      );
      gapController.stalled = 456;
      gapController._reportStall(bufferInfo);
      expect(triggerSpy).to.have.been.calledWith(Events.ERROR, {
        type: ErrorTypes.MEDIA_ERROR,
        details: ErrorDetails.BUFFER_STALLED_ERROR,
        fatal: false,
        error: triggerSpy.getCall(0).lastArg.error,
        buffer: 42,
        bufferInfo,
        stalled: { start: 456 },
      });
    });

    it('should not report a stall if it was already reported', function () {
      const bufferInfo = BufferHelper.bufferedInfo(
        [{ start: 0, end: 42 }],
        0,
        0,
      );
      gapController.stallReported = true;
      gapController.stalled = 456;
      gapController._reportStall(bufferInfo);
      expect(triggerSpy).to.not.have.been.called;
    });
  });

  describe('_tryFixBufferStall', function () {
    it('should nudge when stalling close to the buffer end with multiple ranges', function () {
      const bufferInfo = BufferHelper.bufferedInfo(
        [
          { start: 0, end: 4 },
          { start: 4.1, end: 8 },
        ],
        0,
        0,
      );
      const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000;
      const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer');
      gapController._tryFixBufferStall(bufferInfo, mockStallDuration);
      expect(nudgeStub).to.have.been.calledOnce;
    });

    it('should not nudge when briefly stalling close to the buffer end', function () {
      const bufferInfo = BufferHelper.bufferedInfo(
        [{ start: 0, end: 1 }],
        0,
        0,
      );
      const mockStallDuration = (config.highBufferWatchdogPeriod / 2) * 1000;
      const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer');
      gapController._tryFixBufferStall(bufferInfo, mockStallDuration);
      expect(nudgeStub).to.have.not.been.called;
    });

    it('should not nudge when too far from the buffer end', function () {
      const bufferInfo = BufferHelper.bufferedInfo(
        [{ start: 0, end: 0.09 }],
        0,
        0,
      );
      const mockStallDuration = (config.highBufferWatchdogPeriod + 1) * 1000;
      const nudgeStub = sandbox.stub(gapController, '_tryNudgeBuffer');
      gapController._tryFixBufferStall(bufferInfo, mockStallDuration);
      expect(nudgeStub).to.have.not.been.called;
    });

    it('should try to jump partial fragments when detected', function () {
      const bufferInfo = BufferHelper.bufferedInfo([], 0, 0);
      sandbox
        .stub(gapController.fragmentTracker, 'getPartialFragment')
        .returns({} as unknown as MediaFragment);
      const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole');
      gapController._tryFixBufferStall(bufferInfo, 100);
      expect(skipHoleStub).to.have.been.calledOnce;
    });

    it('should not try to jump partial fragments when none are detected', function () {
      const bufferInfo = BufferHelper.bufferedInfo([], 0, 0);
      sandbox
        .stub(gapController.fragmentTracker, 'getPartialFragment')
        .returns(null);
      const skipHoleStub = sandbox.stub(gapController, '_trySkipBufferHole');
      gapController._tryFixBufferStall(bufferInfo, 100);
      expect(skipHoleStub).to.have.not.been.called;
    });
  });

  describe('media clock polling', function () {
    const TIMER_STEP_MS = 1234;
    const STALL_HANDLING_RETRY_PERIOD_MS = 1000;

    let mockMedia;
    let mockTimeRanges;
    let mockTimeRangesData;
    let reportStallSpy;
    let lastCurrentTime;
    let isStalling;
    let wallClock;

    beforeEach(function () {
      wallClock = sandbox.useFakeTimers(0);
      isStalling = false;
      mockTimeRangesData = [
        [0.1, 0.2],
        [0.4, 0.5],
      ];
      mockTimeRanges = {
        get length() {
          return mockTimeRangesData.length;
        },
        start(index) {
          return mockTimeRangesData[index][0];
        },
        end(index) {
          return mockTimeRangesData[index][1];
        },
      };

      // by default the media
      // is setup in a "playable" state
      // note that the initial current time
      // is within the range of buffered data info
      mockMedia = new MockMediaElement();
      Object.assign(mockMedia, {
        currentTime: 0,
        paused: false,
        seeking: false,
        buffered: mockTimeRanges,
      });

      gapController.media = mockMedia;
      reportStallSpy = sandbox.spy(gapController, '_reportStall');
    });

    // tickMediaClock emulates the behavior
    // of our external polling schedule
    // which would progress as the media clock
    // is altered (or not)
    // when isStalling is false the media clock
    // will not progress while the poll call is done
    function tickMediaClock(incrementSec = 0.1) {
      lastCurrentTime = mockMedia.currentTime;
      if (!isStalling) {
        mockMedia.currentTime += incrementSec;
        gapController.waiting = 0;
      }
      gapController.poll(lastCurrentTime, mockMedia.currentTime);
    }

    function setStalling() {
      gapController.moved = true;
      mockMedia.paused = false;
      mockMedia.currentTime = 4;
      mockTimeRangesData.length = 1;
      mockTimeRangesData[0] = [0, 10];
      lastCurrentTime = 4;
    }

    function setNotStalling() {
      gapController.moved = true;
      mockMedia.paused = false;
      mockMedia.currentTime = 5;
      mockTimeRangesData.length = 1;
      mockTimeRangesData[0] = [0, 10];
      lastCurrentTime = 4;
    }

    it('should try to fix a stall if expected to be playing', function () {
      const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall');
      setStalling();
      gapController.poll(lastCurrentTime, mockMedia.currentTime);

      // The first poll call made while stalling just sets stall flags
      expect(gapController.stalled).to.be.a('number');
      expect(gapController.stallReported).to.be.false;

      gapController.poll(lastCurrentTime, mockMedia.currentTime);
      expect(fixStallStub).to.have.been.calledOnce;
    });

    it('should reset stall flags when no longer stalling', function () {
      setNotStalling();
      gapController.stallReported = true;
      gapController.nudgeRetry = 1;
      gapController.stalled = 4200;
      const fixStallStub = sandbox.stub(gapController, '_tryFixBufferStall');
      gapController.poll(lastCurrentTime, mockMedia.currentTime);

      expect(gapController.stalled).to.not.exist;
      expect(gapController.nudgeRetry).to.equal(0);
      expect(gapController.stallReported).to.be.false;
      expect(fixStallStub).to.not.have.been.called;
    });

    it('should not detect stalls when ended, unbuffered or seeking', function () {
      wallClock.tick(TIMER_STEP_MS);

      // we need to play a bit to get past the moved check
      tickMediaClock();

      isStalling = true;
      mockMedia.ended = true;

      tickMediaClock();
      expect(gapController.stalled).to.equal(null, 'ended');
      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);

      mockMedia.ended = false;
      mockTimeRangesData.length = 0;

      tickMediaClock();
      expect(gapController.stalled).to.equal(null, 'empty buffer');
      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);

      mockTimeRangesData = [
        [0.1, 0.2],
        [0.4, 0.5],
      ];
      mockMedia.seeking = true;

      // tickMediaClock(100)
      expect(gapController.stalled).to.equal(null, 'seeking');
      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);
    });

    it('should not detect stalls when loading an earlier fragment while seeking', function () {
      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);
      mockMedia.currentTime += 0.1;
      gapController.poll(0, mockMedia.currentTime);
      expect(gapController.stalled).to.equal(null, 'buffered start');

      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);
      mockMedia.currentTime += 5;
      mockMedia.seeking = true;
      mockTimeRangesData.length = 1;
      mockTimeRangesData[0] = [5.5, 10];
      gapController.poll(mockMedia.currentTime - 5, mockMedia.currentTime);
      expect(gapController.stalled).to.equal(null, 'new seek position');

      wallClock.tick(2 * STALL_HANDLING_RETRY_PERIOD_MS);
      streamController.state = State.FRAG_LOADING;
      (streamController as any).fragCurrent = {
        start: 5,
      } as unknown as Fragment;
      gapController.poll(mockMedia.currentTime, mockMedia.currentTime);
      expect(gapController.stalled).to.equal(
        null,
        'seeking while loading fragment',
      );
    });

    it('should trigger reportStall when stalling for `detectStallWithCurrentTimeMs` or longer', function () {
      setStalling();
      wallClock.tick(250);
      gapController.stalled = 1;
      gapController.poll(lastCurrentTime, mockMedia.currentTime);
      expect(reportStallSpy).to.not.have.been.called;
      wallClock.tick(config.detectStallWithCurrentTimeMs + 1);
      gapController.poll(lastCurrentTime, mockMedia.currentTime);
      expect(reportStallSpy).to.have.been.calledOnce;
    });

    it('should trigger reportStall when stalling for after waiting event', function () {
      setStalling();
      wallClock.tick(250);
      gapController.stalled = 1;
      gapController.poll(lastCurrentTime, mockMedia.currentTime);
      expect(reportStallSpy).to.not.have.been.called;
      gapController.waiting = 250;
      wallClock.tick(1);
      gapController.poll(lastCurrentTime, mockMedia.currentTime);
      expect(reportStallSpy).to.have.been.calledOnce;
    });

    it('should not handle a stall (clock not advancing) when media has played before and is now paused', function () {
      wallClock.tick(TIMER_STEP_MS);

      tickMediaClock();

      expect(gapController.moved).to.equal(true);
      expect(gapController.stalled).to.equal(null);

      mockMedia.paused = true;
      isStalling = true;

      tickMediaClock();

      expect(gapController.stalled).to.equal(null);

      mockMedia.paused = false;

      tickMediaClock();

      expect(gapController.stalled).to.equal(TIMER_STEP_MS);
    });

    it('should skip any initial gap before playing on the second poll (so that Chrome can jump the gap first)', function () {
      wallClock.tick(TIMER_STEP_MS);

      mockMedia.currentTime = 0;

      isStalling = true;

      tickMediaClock();

      expect(gapController.moved).to.equal(false);
      expect(gapController.stalled).to.equal(1234);
      expect(mockMedia.currentTime).to.equal(0);

      tickMediaClock();

      expect(gapController.moved).to.equal(true);
      expect(gapController.stalled).to.equal(1234);
      expect(mockMedia.currentTime).to.equal(
        0.1 + config.skipBufferHolePadding,
      );
    });

    it('should skip any initial gap when not having played yet on second poll', function () {
      mockMedia.currentTime = 0;
      mockTimeRangesData = [[0.9, 10]];
      gapController.poll(0, mockMedia.currentTime);
      gapController.poll(0, mockMedia.currentTime);
      expect(mockMedia.currentTime).to.equal(
        0.9 + config.skipBufferHolePadding,
      );
    });
  });
});
