import {EbmlStreamDecoder, EbmlTagIdEnum, EbmlTagPosition, type EbmlTagType,} from 'konoebml';
import {
  defer,
  EMPTY,
  filter,
  finalize,
  from,
  isEmpty,
  map,
  merge,
  Observable,
  of,
  reduce,
  scan,
  share,
  shareReplay,
  switchMap,
  take,
  takeWhile,
  withLatestFrom,
} from 'rxjs';
import {createRangedStream, type CreateRangedStreamOptions} from '@/fetch';
import {type CueSystem, SEEK_ID_KAX_CUES, SEEK_ID_KAX_TAGS, type SegmentComponent, SegmentSystem,} from './model';
import {isTagIdPos, waitTick} from './util';
import type {ClusterType} from './schema';

export interface CreateRangedEbmlStreamOptions
  extends CreateRangedStreamOptions {
  tee?: boolean;
}

export function createRangedEbmlStream({
  url,
  byteStart = 0,
  byteEnd,
  tee = false
}: CreateRangedEbmlStreamOptions): Observable<{
  ebml$: Observable<EbmlTagType>;
  totalSize?: number;
  response: Response;
  body: ReadableStream<Uint8Array>;
  controller: AbortController;
  teeBody: ReadableStream<Uint8Array> | undefined;
}> {
  const stream$ = from(createRangedStream({ url, byteStart, byteEnd }));

  return stream$.pipe(
    switchMap(({ controller, body, totalSize, response }) => {
      let requestCompleted = false;
      let teeStream: ReadableStream<Uint8Array> | undefined;

      let stream: ReadableStream<Uint8Array>;

      if (tee) {
        [stream, teeStream] = body.tee();
      } else {
        stream = body;
      }

      const originRequest$ = new Observable<EbmlTagType>((subscriber) => {
        stream
          .pipeThrough(
            new EbmlStreamDecoder({
              streamStartOffset: byteStart,
              collectChild: (child) => child.id !== EbmlTagIdEnum.Cluster,
              backpressure: {
                eventLoop: waitTick,
              },
            })
          )
          .pipeTo(
            new WritableStream({
              write: async (tag) => {
                await waitTick();
                subscriber.next(tag);
              },
              close: () => {
                if (!requestCompleted) {
                  requestCompleted = true;
                  subscriber.complete();
                }
              },
            })
          )
          .catch((error) => {
            if (requestCompleted && error?.name === 'AbortError') {
              return;
            }
            requestCompleted = true;
            subscriber.error(error);
          });

        return () => {
          requestCompleted = true;
          controller.abort();
        };
      }).pipe(
        share({
          resetOnComplete: false,
          resetOnError: false,
          resetOnRefCountZero: true,
        })
      );

      const ebml$ = defer(() =>
        requestCompleted ? EMPTY : originRequest$
      ).pipe(
        share({
          resetOnError: false,
          resetOnComplete: true,
          resetOnRefCountZero: true,
        })
      );

      return of({
        ebml$,
        totalSize,
        response,
        body: stream,
        teeBody: teeStream,
        controller,
      });
    })
  );
}

export interface CreateEbmlControllerOptions
  extends Omit<CreateRangedEbmlStreamOptions, 'byteStart' | 'byteEnd'> {}

export function createEbmlController({
  url,
  ...options
}: CreateEbmlControllerOptions) {
  const metaRequest$ = createRangedEbmlStream({
    ...options,
    url,
    byteStart: 0,
    tee: true
  });

  const controller$ = metaRequest$.pipe(
    map(({ totalSize, ebml$, response, controller, teeBody }) => {

      const head$ = ebml$.pipe(
        filter(isTagIdPos(EbmlTagIdEnum.EBML, EbmlTagPosition.End)),
        take(1),
        shareReplay(1)
      );

      console.debug(
        `stream of video "${url}" created, total size is ${totalSize ?? 'unknown'}`
      );

      const segmentStart$ = ebml$.pipe(
        filter(isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.Start))
      );

      /**
       * while [matroska v4](https://www.matroska.org/technical/elements.html) doc tell that there is only one segment in a file
       * some mkv generated by strange tools will emit several
       */
      const segments$ = segmentStart$.pipe(
        map((startTag) => {
          const segment = new SegmentSystem(startTag, teeBody!);
          const clusterSystem = segment.cluster;
          const seekSystem = segment.seek;

          const meta$ = ebml$.pipe(
            scan((acc, tag) => {
              // avoid object recreation
              acc.hasKeyframe = acc.hasKeyframe || (tag.id === EbmlTagIdEnum.SimpleBlock && tag.keyframe) || (tag.id === EbmlTagIdEnum.BlockGroup && tag.children.every(c => c.id !== EbmlTagIdEnum.ReferenceBlock));
              acc.tag = tag;
              return acc;
            }, { hasKeyframe: false, tag: undefined as unknown as EbmlTagType }),
            takeWhile(
              ({ tag, hasKeyframe }) => {
                return !isTagIdPos(EbmlTagIdEnum.Segment, EbmlTagPosition.End)(tag) && !(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)(tag) && hasKeyframe);
              },
              true
            ),
            map(({ tag }) => tag),
            share({
              resetOnComplete: false,
              resetOnError: false,
              resetOnRefCountZero: true,
            })
          );

          const withMeta$ = meta$.pipe(
            reduce((segment, meta) => segment.scanMeta(meta), segment),
            switchMap(() => segment.completeMeta()),
            take(1),
            shareReplay(1)
          );

          const withRemoteCues$ = withMeta$.pipe(
            switchMap((s) => {
              const cueSystem = s.cue;
              const seekSystem = s.seek;
              if (cueSystem.prepared) {
                return EMPTY;
              }
              const remoteCuesTagStartOffset =
                seekSystem.seekOffsetBySeekId(SEEK_ID_KAX_CUES);
              if (remoteCuesTagStartOffset! >= 0) {
                return createRangedEbmlStream({
                  ...options,
                  url,
                  byteStart: remoteCuesTagStartOffset,
                }).pipe(
                  switchMap((req) => req.ebml$),
                  filter(isTagIdPos(EbmlTagIdEnum.Cues, EbmlTagPosition.End)),
                  withLatestFrom(withMeta$),
                  map(([cues, withMeta]) => {
                    withMeta.cue.prepareCuesWithTag(cues);
                    return withMeta;
                  })
                );
              }
              return EMPTY;
            }),
            take(1),
            shareReplay(1)
          );

          const withLocalCues$ = withMeta$.pipe(
            switchMap((s) => (s.cue.prepared ? of(s) : EMPTY)),
            shareReplay(1)
          );

          const withRemoteTags$ = withMeta$.pipe(
            switchMap((s) => {
              const tagSystem = s.tag;
              const seekSystem = s.seek;
              if (tagSystem.prepared) {
                return EMPTY;
              }

              const remoteTagsTagStartOffset =
                seekSystem.seekOffsetBySeekId(SEEK_ID_KAX_TAGS);
              if (remoteTagsTagStartOffset! >= 0) {
                return createRangedEbmlStream({
                  ...options,
                  url,
                  byteStart: remoteTagsTagStartOffset,
                }).pipe(
                  switchMap((req) => req.ebml$),
                  filter(isTagIdPos(EbmlTagIdEnum.Tags, EbmlTagPosition.End)),
                  withLatestFrom(withMeta$),
                  map(([tags, withMeta]) => {
                    withMeta.tag.prepareTagsWithTag(tags);
                    return withMeta;
                  })
                );
              }
              return EMPTY;
            }),
            take(1),
            shareReplay(1)
          );

          const withLocalTags$ = withMeta$.pipe(
            switchMap((s) => (s.tag.prepared ? of(s) : EMPTY)),
            shareReplay(1)
          );

          const withCues$ = merge(withLocalCues$, withRemoteCues$).pipe(
            take(1)
          );

          const withoutCues$ = withCues$.pipe(
            isEmpty(),
            switchMap((empty) => (empty ? withMeta$ : EMPTY))
          );

          const withTags$ = merge(withLocalTags$, withRemoteTags$).pipe(
            take(1)
          );

          const withoutTags$ = withTags$.pipe(
            isEmpty(),
            switchMap((empty) => (empty ? withMeta$ : EMPTY))
          );

          const seekWithoutCues = (
            seekTime: number
          ): Observable<SegmentComponent<ClusterType>> => {
            const request$ = withMeta$.pipe(
              switchMap(() =>
                createRangedEbmlStream({
                  ...options,
                  url,
                  byteStart: seekSystem.firstClusterOffset,
                })
              )
            );
            const cluster$ = request$.pipe(
              switchMap((req) => req.ebml$),
              filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
              map((tag) => clusterSystem.addClusterWithTag(tag))
            );

            if (seekTime === 0) {
              return cluster$;
            }

            return cluster$.pipe(
              scan(
                (acc, curr) => {
                  // avoid object recreation
                  acc.prev = acc.next;
                  acc.next = curr;
                  return acc;
                },
                ({ prev: undefined as (SegmentComponent<ClusterType> | undefined), next: undefined as SegmentComponent<ClusterType> | undefined })
              ),
              filter((c) => c.next?.Timestamp! > seekTime),
              map((c) => c.prev ?? c.next!)
            );
          };

          const seekWithCues = (
            cueSystem: CueSystem,
            seekTime: number
          ): Observable<SegmentComponent<ClusterType>> => {
            if (seekTime === 0) {
              return seekWithoutCues(seekTime);
            }

            const cuePoint = cueSystem.findClosestCue(seekTime);

            if (!cuePoint) {
              return seekWithoutCues(seekTime);
            }

            return createRangedEbmlStream({
              ...options,
              url,
              byteStart: seekSystem.offsetFromSeekPosition(
                cueSystem.getCueTrackPositions(cuePoint)
                  .CueClusterPosition as number
              ),
            }).pipe(
              switchMap((req) => req.ebml$),
              filter(isTagIdPos(EbmlTagIdEnum.Cluster, EbmlTagPosition.End)),
              map(clusterSystem.addClusterWithTag.bind(clusterSystem))
            );
          };

          const seek = (
            seekTime: number
          ): Observable<SegmentComponent<ClusterType>> => {
            if (seekTime === 0) {
              const subscription = merge(withCues$, withoutCues$).subscribe();

              // if seekTime equals to 0 at start, reuse the initialize stream
              return seekWithoutCues(seekTime).pipe(
                finalize(() => {
                  subscription.unsubscribe();
                })
              );
            }
            return merge(
              withCues$.pipe(switchMap((s) => seekWithCues(s.cue, seekTime))),
              withoutCues$.pipe(switchMap((_) => seekWithoutCues(seekTime)))
            );
          };

          return {
            startTag,
            head$,
            segment,
            meta$,
            withMeta$,
            withCues$,
            withoutCues$,
            withTags$,
            withoutTags$,
            seekWithCues,
            seekWithoutCues,
            seek,
          };
        })
      );

      return {
        segments$,
        head$,
        totalSize,
        ebml$,
        controller,
        response,
      };
    }),
    shareReplay(1)
  );

  return {
    controller$,
    request$: metaRequest$,
  };
}
