import chai from 'chai';
import sinonChai from 'sinon-chai';
import {
  FragmentState,
  FragmentTracker,
} from '../../../src/controller/fragment-tracker';
import { Events } from '../../../src/events';
import Hls from '../../../src/hls';
import { ElementaryStreamTypes, Fragment } from '../../../src/loader/fragment';
import { LoadStats } from '../../../src/loader/load-stats';
import { PlaylistLevelType } from '../../../src/types/loader';
import { ChunkMetadata } from '../../../src/types/transmuxer';
import type {
  BufferAppendedData,
  FragBufferedData,
  FragLoadedData,
} from '../../../src/types/events';

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

describe('FragmentTracker', function () {
  describe('getPartialFragment', function () {
    const hls = new Hls({});
    const fragmentTracker = new FragmentTracker(hls);

    const fragment = createMockFragment(
      {
        startPTS: 0,
        endPTS: 1,
        sn: 1,
        level: 1,
        type: PlaylistLevelType.MAIN,
      },
      [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
    );

    triggerFragLoaded(hls, fragment);

    hls.trigger(
      Events.BUFFER_APPENDED,
      createBufferAppendedData([
        {
          startPTS: 0,
          endPTS: 0.5,
        },
      ]),
    );

    hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

    it('detects fragments that partially loaded', function () {
      // Get the partial fragment at a time
      const partialFragment1 = fragmentTracker.getPartialFragment(0);
      expect(partialFragment1).to.equal(fragment);
      const partialFragment2 = fragmentTracker.getPartialFragment(0.5);
      expect(partialFragment2).to.equal(fragment);
      const partialFragment3 = fragmentTracker.getPartialFragment(1);
      expect(partialFragment3).to.equal(fragment);
    });

    it('returns null when time is not inside partial fragment', function () {
      const partialFragment = fragmentTracker.getPartialFragment(1.5);
      expect(partialFragment).to.not.exist;
    });
  });

  describe('getState', function () {
    const hls = new Hls({});
    const fragmentTracker = new FragmentTracker(hls);

    const addFragment = function (): Fragment {
      const fragment = createMockFragment(
        {
          startPTS: 0,
          endPTS: 1,
          sn: 1,
          level: 0,
          type: PlaylistLevelType.MAIN,
        },
        [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
      );
      triggerFragLoaded(hls, fragment);
      return fragment;
    };

    it('detects fragments that never loaded', function () {
      const fragment = addFragment();
      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.APPENDING,
      );
    });

    it('detects fragments that loaded properly', function () {
      const fragment = addFragment();
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0,
            endPTS: 1,
          },
        ]),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(FragmentState.OK);
    });

    it('detects partial fragments', function () {
      const fragment = addFragment();
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0.5,
            endPTS: 2,
          },
        ]),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.PARTIAL,
      );
    });

    it('removes evicted partial fragments', function () {
      const fragment = addFragment();
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0.5,
            endPTS: 2,
          },
        ]),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.PARTIAL,
      );

      // Trim the buffer
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0.75,
            endPTS: 2,
          },
        ]),
      );

      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.NOT_LOADED,
      );
    });
  });

  describe('getBufferedFrag', function () {
    let hls;
    let fragmentTracker: FragmentTracker;
    beforeEach(function () {
      hls = new Hls({});
      fragmentTracker = new FragmentTracker(hls);
    });
    it('should return buffered fragment if found it', function () {
      const fragments = [
        // 0-1
        createMockFragment(
          {
            startPTS: 0,
            endPTS: 1,
            sn: 1,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 1-2
        createMockFragment(
          {
            startPTS: 1,
            endPTS: 2,
            sn: 2,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 2-3
        createMockFragment(
          {
            startPTS: 2,
            endPTS: 3,
            sn: 3,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
      ];
      // load fragments to buffered
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0,
            endPTS: 3,
          },
        ]),
      );
      fragments.forEach((fragment) => {
        triggerFragLoadedAndFragBuffered(hls, fragment);
      });
      expect(
        fragmentTracker.getBufferedFrag(0.0, PlaylistLevelType.MAIN),
      ).to.equal(fragments[0]);
      expect(
        fragmentTracker.getBufferedFrag(0.1, PlaylistLevelType.MAIN),
      ).to.equal(fragments[0]);
      expect(
        fragmentTracker.getBufferedFrag(1.0, PlaylistLevelType.MAIN),
      ).to.equal(fragments[1]);
      expect(
        fragmentTracker.getBufferedFrag(1.1, PlaylistLevelType.MAIN),
      ).to.equal(fragments[1]);
      expect(
        fragmentTracker.getBufferedFrag(2.0, PlaylistLevelType.MAIN),
      ).to.equal(fragments[2]);
      expect(
        fragmentTracker.getBufferedFrag(2.1, PlaylistLevelType.MAIN),
      ).to.equal(fragments[2]);
      expect(
        fragmentTracker.getBufferedFrag(2.9, PlaylistLevelType.MAIN),
      ).to.equal(fragments[2]);
      expect(
        fragmentTracker.getBufferedFrag(3.0, PlaylistLevelType.MAIN),
      ).to.equal(fragments[2]);
    });
    it('should return null if found it, but it is not buffered', function () {
      const fragments = [
        // 0-1
        createMockFragment(
          {
            startPTS: 0,
            endPTS: 1,
            sn: 1,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 1-2
        createMockFragment(
          {
            startPTS: 1,
            endPTS: 2,
            sn: 2,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 2-3
        createMockFragment(
          {
            startPTS: 2,
            endPTS: 3,
            sn: 3,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
      ];
      // load fragments, but it is not buffered
      fragments.forEach((fragment) => {
        triggerFragLoaded(hls, fragment);
      });
      expect(fragmentTracker.getBufferedFrag(0, PlaylistLevelType.MAIN)).to.not
        .exist;
      expect(fragmentTracker.getBufferedFrag(1, PlaylistLevelType.MAIN)).to.not
        .exist;
      expect(fragmentTracker.getBufferedFrag(2, PlaylistLevelType.MAIN)).to.not
        .exist;
      expect(fragmentTracker.getBufferedFrag(3, PlaylistLevelType.MAIN)).to.not
        .exist;
    });
    it('should return null if anyone does not match the position', function () {
      triggerFragLoadedAndFragBuffered(
        hls,
        createMockFragment(
          {
            startPTS: 0,
            endPTS: 1,
            sn: 1,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
      );
      // not found
      expect(fragmentTracker.getBufferedFrag(1.1, PlaylistLevelType.MAIN)).to
        .not.exist;
    });
    it('should return null if fragmentTracker not have any fragments', function () {
      expect(fragmentTracker.getBufferedFrag(0, PlaylistLevelType.MAIN)).to.not
        .exist;
    });
    it('should return null if not found match levelType', function () {
      triggerFragLoadedAndFragBuffered(
        hls,
        createMockFragment(
          {
            startPTS: 0,
            endPTS: 1,
            sn: 1,
            level: 1,
            type: PlaylistLevelType.AUDIO, // <= level type is not "main"
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
      );

      expect(fragmentTracker.getBufferedFrag(0, PlaylistLevelType.MAIN)).to.not
        .exist;
    });
  });

  describe('onFragBuffered', function () {
    let fragment;

    const hls = new Hls({});
    const fragmentTracker = new FragmentTracker(hls);

    it('supports audio buffer', function () {
      fragment = createMockFragment(
        {
          startPTS: 0,
          endPTS: 1,
          sn: 1,
          level: 1,
          type: PlaylistLevelType.MAIN,
        },
        [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
      );

      triggerFragLoaded(hls, fragment);

      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData(
          [
            {
              startPTS: 0,
              endPTS: 2,
            },
          ],
          [
            {
              startPTS: 0.5,
              endPTS: 2,
            },
          ],
        ),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.PARTIAL,
      );
    });

    it('supports video buffer', function () {
      fragment = createMockFragment(
        {
          startPTS: 0,
          endPTS: 1,
          sn: 1,
          level: 1,
          type: PlaylistLevelType.MAIN,
        },
        [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
      );

      triggerFragLoaded(hls, fragment);

      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData(
          [
            {
              startPTS: 0.5,
              endPTS: 2,
            },
          ],
          [
            {
              startPTS: 0,
              endPTS: 2,
            },
          ],
        ),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(
        FragmentState.PARTIAL,
      );
    });

    it('supports audio only buffer', function () {
      fragment = createMockFragment(
        {
          startPTS: 0,
          endPTS: 1,
          sn: 1,
          level: 1,
          type: PlaylistLevelType.AUDIO,
        },
        [ElementaryStreamTypes.AUDIO],
      );

      triggerFragLoaded(hls, fragment);

      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData(
          [
            {
              startPTS: 0.5,
              endPTS: 2,
            },
          ],
          [
            {
              startPTS: 0,
              endPTS: 2,
            },
          ],
        ),
      );

      hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment, true));

      expect(fragmentTracker.getState(fragment)).to.equal(FragmentState.OK);
    });
  });

  type FragmentTrackerTestable = Omit<
    FragmentTracker,
    'hasFragment' | 'removeFragment'
  > & {
    hasFragment: (fragment: Fragment) => void;
    removeFragment: (fragment: Fragment) => void;
  };

  describe('removeFragment', function () {
    let hls: Hls;
    let fragmentTracker: FragmentTrackerTestable;
    beforeEach(function () {
      hls = new Hls({});
      fragmentTracker = new FragmentTracker(
        hls,
      ) as unknown as FragmentTrackerTestable;
    });
    it('should remove fragment', function () {
      const fragment = createMockFragment(
        {
          startPTS: 0,
          endPTS: 1,
          sn: 1,
          level: 1,
          type: PlaylistLevelType.MAIN,
        },
        [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
      );
      // load fragments to buffered
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0,
            endPTS: 1,
          },
        ]),
      );
      triggerFragLoadedAndFragBuffered(hls, fragment);
      expect(fragmentTracker.hasFragment(fragment)).to.be.true;
      // Remove the fragment
      fragmentTracker.removeFragment(fragment);
      // Check
      expect(fragmentTracker.hasFragment(fragment)).to.be.false;
    });
  });
  describe('removeAllFragments', function () {
    let hls: Hls;
    let fragmentTracker: FragmentTrackerTestable;
    beforeEach(function () {
      hls = new Hls({});
      fragmentTracker = new FragmentTracker(
        hls,
      ) as unknown as FragmentTrackerTestable;
    });
    it('should remove all fragments', function () {
      const fragments = [
        // 0-1
        createMockFragment(
          {
            startPTS: 0,
            endPTS: 1,
            sn: 1,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 1-2
        createMockFragment(
          {
            startPTS: 1,
            endPTS: 2,
            sn: 2,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
        // 2-3
        createMockFragment(
          {
            startPTS: 2,
            endPTS: 3,
            sn: 3,
            level: 1,
            type: PlaylistLevelType.MAIN,
          },
          [ElementaryStreamTypes.AUDIO, ElementaryStreamTypes.VIDEO],
        ),
      ];
      // load fragments to buffered
      hls.trigger(
        Events.BUFFER_APPENDED,
        createBufferAppendedData([
          {
            startPTS: 0,
            endPTS: 3,
          },
        ]),
      );
      fragments.forEach((fragment) => {
        triggerFragLoadedAndFragBuffered(hls, fragment);
      });
      // before
      fragments.forEach((fragment) => {
        expect(
          fragmentTracker.hasFragment(fragment),
          'has fragments before removing',
        ).to.be.true;
      });
      // Remove all fragments
      fragmentTracker.removeAllFragments();
      // after
      fragments.forEach((fragment) => {
        expect(
          fragmentTracker.hasFragment(fragment),
          'has not fragments after removing',
        ).to.be.false;
      });
    });
  });
});

function triggerFragLoaded(hls: Hls, fragment: Fragment) {
  hls.trigger(Events.FRAG_LOADED, createFragLoadedData(fragment));
}

function triggerFragLoadedAndFragBuffered(hls: Hls, fragment: Fragment) {
  triggerFragLoaded(hls, fragment);
  hls.trigger(Events.FRAG_BUFFERED, createFragBufferedData(fragment));
}

type PtsTimeRanges = Array<{ startPTS: number; endPTS: number }>;

function createMockBuffer(buffered: PtsTimeRanges): TimeRanges {
  return {
    start: (i) => buffered[i].startPTS,
    end: (i) => buffered[i].endPTS,
    length: buffered.length,
  };
}

function createBufferAppendedData(
  video: PtsTimeRanges,
  audio?: PtsTimeRanges,
): BufferAppendedData {
  return {
    chunkMeta: new ChunkMetadata(0, 0, 0, 0),
    frag: new Fragment(PlaylistLevelType.MAIN, ''),
    part: null,
    parent: PlaylistLevelType.MAIN,
    type: audio ? 'audiovideo' : 'video',
    timeRanges: {
      video: createMockBuffer(video),
      audio: createMockBuffer(audio || video),
    },
  };
}

function createFragBufferedData(
  frag: Fragment,
  aborted?: boolean,
): FragBufferedData {
  const stats = new LoadStats();
  if (aborted) {
    stats.aborted = aborted;
  }
  return {
    stats,
    frag,
    part: null,
    id: frag.type,
  };
}

function createFragLoadedData(frag: Fragment): FragLoadedData {
  return {
    frag,
    part: null,
    payload: new ArrayBuffer(0),
    networkDetails: null,
  };
}

type MockFragmentParams = {
  startPTS: number;
  endPTS: number;
  sn: number;
  level: number;
  type: PlaylistLevelType;
};

function createMockFragment(
  data: MockFragmentParams,
  types: ElementaryStreamTypes[],
): Fragment {
  const frag = new Fragment(data.type, '');
  Object.assign(frag, data);
  frag.setStart(data.startPTS);
  frag.duration = data.endPTS - data.startPTS;
  types.forEach((t) => {
    frag.setElementaryStreamInfo(
      t,
      data.startPTS,
      data.endPTS,
      data.startPTS,
      data.endPTS,
    );
  });
  frag.relurl = 'not-frag-hint';
  return frag;
}
