/*
 * Copyright (C) 2014 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.telegram.messenger.exoplayer.chunk;

import org.telegram.messenger.exoplayer.C;
import org.telegram.messenger.exoplayer.LoadControl;
import org.telegram.messenger.exoplayer.MediaFormat;
import org.telegram.messenger.exoplayer.MediaFormatHolder;
import org.telegram.messenger.exoplayer.SampleHolder;
import org.telegram.messenger.exoplayer.SampleSource;
import org.telegram.messenger.exoplayer.SampleSource.SampleSourceReader;
import org.telegram.messenger.exoplayer.TrackRenderer;
import org.telegram.messenger.exoplayer.extractor.DefaultTrackOutput;
import org.telegram.messenger.exoplayer.upstream.Loader;
import org.telegram.messenger.exoplayer.upstream.Loader.Loadable;
import org.telegram.messenger.exoplayer.util.Assertions;

import android.os.Handler;
import android.os.SystemClock;

import java.io.IOException;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;

/**
 * A {@link SampleSource} that loads media in {@link Chunk}s, which are themselves obtained from a
 * {@link ChunkSource}.
 */
public class ChunkSampleSource implements SampleSource, SampleSourceReader, Loader.Callback {

  /**
   * Interface definition for a callback to be notified of {@link ChunkSampleSource} events.
   */
  public interface EventListener extends BaseChunkSampleSourceEventListener {}

  /**
   * The default minimum number of times to retry loading data prior to failing.
   */
  public static final int DEFAULT_MIN_LOADABLE_RETRY_COUNT = 3;

  protected final DefaultTrackOutput sampleQueue;

  private static final int STATE_IDLE = 0;
  private static final int STATE_INITIALIZED = 1;
  private static final int STATE_PREPARED = 2;
  private static final int STATE_ENABLED = 3;

  private static final long NO_RESET_PENDING = Long.MIN_VALUE;

  private final int eventSourceId;
  private final LoadControl loadControl;
  private final ChunkSource chunkSource;
  private final ChunkOperationHolder currentLoadableHolder;
  private final LinkedList<BaseMediaChunk> mediaChunks;
  private final List<BaseMediaChunk> readOnlyMediaChunks;
  private final int bufferSizeContribution;
  private final Handler eventHandler;
  private final EventListener eventListener;
  private final int minLoadableRetryCount;

  private int state;
  private long downstreamPositionUs;
  private long lastSeekPositionUs;
  private long pendingResetPositionUs;
  private long lastPerformedBufferOperation;
  private boolean pendingDiscontinuity;

  private Loader loader;
  private boolean loadingFinished;
  private IOException currentLoadableException;
  private int enabledTrackCount;
  private int currentLoadableExceptionCount;
  private long currentLoadableExceptionTimestamp;
  private long currentLoadStartTimeMs;

  private MediaFormat downstreamMediaFormat;
  private Format downstreamFormat;

  /**
   * @param chunkSource A {@link ChunkSource} from which chunks to load are obtained.
   * @param loadControl Controls when the source is permitted to load data.
   * @param bufferSizeContribution The contribution of this source to the media buffer, in bytes.
   */
  public ChunkSampleSource(ChunkSource chunkSource, LoadControl loadControl,
      int bufferSizeContribution) {
    this(chunkSource, loadControl, bufferSizeContribution, null, null, 0);
  }

  /**
   * @param chunkSource A {@link ChunkSource} from which chunks to load are obtained.
   * @param loadControl Controls when the source is permitted to load data.
   * @param bufferSizeContribution The contribution of this source to the media buffer, in bytes.
   * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
   *     null if delivery of events is not required.
   * @param eventListener A listener of events. May be null if delivery of events is not required.
   * @param eventSourceId An identifier that gets passed to {@code eventListener} methods.
   */
  public ChunkSampleSource(ChunkSource chunkSource, LoadControl loadControl,
      int bufferSizeContribution, Handler eventHandler, EventListener eventListener,
      int eventSourceId) {
    this(chunkSource, loadControl, bufferSizeContribution, eventHandler, eventListener,
        eventSourceId, DEFAULT_MIN_LOADABLE_RETRY_COUNT);
  }

  /**
   * @param chunkSource A {@link ChunkSource} from which chunks to load are obtained.
   * @param loadControl Controls when the source is permitted to load data.
   * @param bufferSizeContribution The contribution of this source to the media buffer, in bytes.
   * @param eventHandler A handler to use when delivering events to {@code eventListener}. May be
   *     null if delivery of events is not required.
   * @param eventListener A listener of events. May be null if delivery of events is not required.
   * @param eventSourceId An identifier that gets passed to {@code eventListener} methods.
   * @param minLoadableRetryCount The minimum number of times that the source should retry a load
   *     before propagating an error.
   */
  public ChunkSampleSource(ChunkSource chunkSource, LoadControl loadControl,
      int bufferSizeContribution, Handler eventHandler, EventListener eventListener,
      int eventSourceId, int minLoadableRetryCount) {
    this.chunkSource = chunkSource;
    this.loadControl = loadControl;
    this.bufferSizeContribution = bufferSizeContribution;
    this.eventHandler = eventHandler;
    this.eventListener = eventListener;
    this.eventSourceId = eventSourceId;
    this.minLoadableRetryCount = minLoadableRetryCount;
    currentLoadableHolder = new ChunkOperationHolder();
    mediaChunks = new LinkedList<>();
    readOnlyMediaChunks = Collections.unmodifiableList(mediaChunks);
    sampleQueue = new DefaultTrackOutput(loadControl.getAllocator());
    state = STATE_IDLE;
    pendingResetPositionUs = NO_RESET_PENDING;
  }

  @Override
  public SampleSourceReader register() {
    Assertions.checkState(state == STATE_IDLE);
    state = STATE_INITIALIZED;
    return this;
  }

  @Override
  public boolean prepare(long positionUs) {
    Assertions.checkState(state == STATE_INITIALIZED || state == STATE_PREPARED);
    if (state == STATE_PREPARED) {
      return true;
    } else if (!chunkSource.prepare()) {
      return false;
    }
    if (chunkSource.getTrackCount() > 0) {
      loader = new Loader("Loader:" + chunkSource.getFormat(0).mimeType);
    }
    state = STATE_PREPARED;
    return true;
  }

  @Override
  public int getTrackCount() {
    Assertions.checkState(state == STATE_PREPARED || state == STATE_ENABLED);
    return chunkSource.getTrackCount();
  }

  @Override
  public MediaFormat getFormat(int track) {
    Assertions.checkState(state == STATE_PREPARED || state == STATE_ENABLED);
    return chunkSource.getFormat(track);
  }

  @Override
  public void enable(int track, long positionUs) {
    Assertions.checkState(state == STATE_PREPARED);
    Assertions.checkState(enabledTrackCount++ == 0);
    state = STATE_ENABLED;
    chunkSource.enable(track);
    loadControl.register(this, bufferSizeContribution);
    downstreamFormat = null;
    downstreamMediaFormat = null;
    downstreamPositionUs = positionUs;
    lastSeekPositionUs = positionUs;
    pendingDiscontinuity = false;
    restartFrom(positionUs);
  }

  @Override
  public void disable(int track) {
    Assertions.checkState(state == STATE_ENABLED);
    Assertions.checkState(--enabledTrackCount == 0);
    state = STATE_PREPARED;
    try {
      chunkSource.disable(mediaChunks);
    } finally {
      loadControl.unregister(this);
      if (loader.isLoading()) {
        loader.cancelLoading();
      } else {
        sampleQueue.clear();
        mediaChunks.clear();
        clearCurrentLoadable();
        loadControl.trimAllocator();
      }
    }
  }

  @Override
  public boolean continueBuffering(int track, long positionUs) {
    Assertions.checkState(state == STATE_ENABLED);
    downstreamPositionUs = positionUs;
    chunkSource.continueBuffering(positionUs);
    updateLoadControl();
    return loadingFinished || !sampleQueue.isEmpty();
  }

  @Override
  public long readDiscontinuity(int track) {
    if (pendingDiscontinuity) {
      pendingDiscontinuity = false;
      return lastSeekPositionUs;
    }
    return NO_DISCONTINUITY;
  }

  @Override
  public int readData(int track, long positionUs, MediaFormatHolder formatHolder,
      SampleHolder sampleHolder) {
    Assertions.checkState(state == STATE_ENABLED);
    downstreamPositionUs = positionUs;

    if (pendingDiscontinuity || isPendingReset()) {
      return NOTHING_READ;
    }

    boolean haveSamples = !sampleQueue.isEmpty();
    BaseMediaChunk currentChunk = mediaChunks.getFirst();
    while (haveSamples && mediaChunks.size() > 1
        && mediaChunks.get(1).getFirstSampleIndex() <= sampleQueue.getReadIndex()) {
      mediaChunks.removeFirst();
      currentChunk = mediaChunks.getFirst();
    }

    Format format = currentChunk.format;
    if (!format.equals(downstreamFormat)) {
      notifyDownstreamFormatChanged(format, currentChunk.trigger, currentChunk.startTimeUs);
    }
    downstreamFormat = format;

    if (haveSamples || currentChunk.isMediaFormatFinal) {
      MediaFormat mediaFormat = currentChunk.getMediaFormat();
      if (!mediaFormat.equals(downstreamMediaFormat)) {
        formatHolder.format = mediaFormat;
        formatHolder.drmInitData = currentChunk.getDrmInitData();
        downstreamMediaFormat = mediaFormat;
        return FORMAT_READ;
      }
      // If mediaFormat and downstreamMediaFormat are equal but different objects then the equality
      // check above will have been expensive, comparing the fields in each format. We update
      // downstreamMediaFormat here so that referential equality can be cheaply established during
      // subsequent calls.
      downstreamMediaFormat = mediaFormat;
    }

    if (!haveSamples) {
      if (loadingFinished) {
        return END_OF_STREAM;
      }
      return NOTHING_READ;
    }

    if (sampleQueue.getSample(sampleHolder)) {
      boolean decodeOnly = sampleHolder.timeUs < lastSeekPositionUs;
      sampleHolder.flags |= decodeOnly ? C.SAMPLE_FLAG_DECODE_ONLY : 0;
      onSampleRead(currentChunk, sampleHolder);
      return SAMPLE_READ;
    }

    return NOTHING_READ;
  }

  @Override
  public void seekToUs(long positionUs) {
    Assertions.checkState(state == STATE_ENABLED);

    long currentPositionUs = isPendingReset() ? pendingResetPositionUs : downstreamPositionUs;
    downstreamPositionUs = positionUs;
    lastSeekPositionUs = positionUs;
    if (currentPositionUs == positionUs) {
      return;
    }

    // If we're not pending a reset, see if we can seek within the sample queue.
    boolean seekInsideBuffer = !isPendingReset() && sampleQueue.skipToKeyframeBefore(positionUs);
    if (seekInsideBuffer) {
      // We succeeded. All we need to do is discard any chunks that we've moved past.
      boolean haveSamples = !sampleQueue.isEmpty();
      while (haveSamples && mediaChunks.size() > 1
          && mediaChunks.get(1).getFirstSampleIndex() <= sampleQueue.getReadIndex()) {
        mediaChunks.removeFirst();
      }
    } else {
      // We failed, and need to restart.
      restartFrom(positionUs);
    }
    // Either way, we need to send a discontinuity to the downstream components.
    pendingDiscontinuity = true;
  }

  @Override
  public void maybeThrowError() throws IOException {
    if (currentLoadableException != null && currentLoadableExceptionCount > minLoadableRetryCount) {
      throw currentLoadableException;
    } else if (currentLoadableHolder.chunk == null) {
      chunkSource.maybeThrowError();
    }
  }

  @Override
  public long getBufferedPositionUs() {
    Assertions.checkState(state == STATE_ENABLED);
    if (isPendingReset()) {
      return pendingResetPositionUs;
    } else if (loadingFinished) {
      return TrackRenderer.END_OF_TRACK_US;
    } else {
      long largestParsedTimestampUs = sampleQueue.getLargestParsedTimestampUs();
      return largestParsedTimestampUs == Long.MIN_VALUE ? downstreamPositionUs
          : largestParsedTimestampUs;
    }
  }

  @Override
  public void release() {
    Assertions.checkState(state != STATE_ENABLED);
    if (loader != null) {
      loader.release();
      loader = null;
    }
    state = STATE_IDLE;
  }

  @Override
  public void onLoadCompleted(Loadable loadable) {
    long now = SystemClock.elapsedRealtime();
    long loadDurationMs = now - currentLoadStartTimeMs;
    Chunk currentLoadable = currentLoadableHolder.chunk;
    chunkSource.onChunkLoadCompleted(currentLoadable);
    if (isMediaChunk(currentLoadable)) {
      BaseMediaChunk mediaChunk = (BaseMediaChunk) currentLoadable;
      notifyLoadCompleted(currentLoadable.bytesLoaded(), mediaChunk.type, mediaChunk.trigger,
          mediaChunk.format, mediaChunk.startTimeUs, mediaChunk.endTimeUs, now, loadDurationMs);
    } else {
      notifyLoadCompleted(currentLoadable.bytesLoaded(), currentLoadable.type,
          currentLoadable.trigger, currentLoadable.format, -1, -1, now, loadDurationMs);
    }
    clearCurrentLoadable();
    updateLoadControl();
  }

  @Override
  public void onLoadCanceled(Loadable loadable) {
    Chunk currentLoadable = currentLoadableHolder.chunk;
    notifyLoadCanceled(currentLoadable.bytesLoaded());
    clearCurrentLoadable();
    if (state == STATE_ENABLED) {
      restartFrom(pendingResetPositionUs);
    } else {
      sampleQueue.clear();
      mediaChunks.clear();
      clearCurrentLoadable();
      loadControl.trimAllocator();
    }
  }

  @Override
  public void onLoadError(Loadable loadable, IOException e) {
    currentLoadableException = e;
    currentLoadableExceptionCount++;
    currentLoadableExceptionTimestamp = SystemClock.elapsedRealtime();
    notifyLoadError(e);
    chunkSource.onChunkLoadError(currentLoadableHolder.chunk, e);
    updateLoadControl();
  }

  /**
   * Called when a sample has been read. Can be used to perform any modifications necessary before
   * the sample is returned.
   *
   * @param mediaChunk The chunk from which the sample was obtained.
   * @param sampleHolder Holds the read sample.
   */
  protected void onSampleRead(MediaChunk mediaChunk, SampleHolder sampleHolder) {
    // Do nothing.
  }

  private void restartFrom(long positionUs) {
    pendingResetPositionUs = positionUs;
    loadingFinished = false;
    if (loader.isLoading()) {
      loader.cancelLoading();
    } else {
      sampleQueue.clear();
      mediaChunks.clear();
      clearCurrentLoadable();
      updateLoadControl();
    }
  }

  private void clearCurrentLoadable() {
    currentLoadableHolder.chunk = null;
    clearCurrentLoadableException();
  }

  private void clearCurrentLoadableException() {
    currentLoadableException = null;
    currentLoadableExceptionCount = 0;
  }

  private void updateLoadControl() {
    long now = SystemClock.elapsedRealtime();
    long nextLoadPositionUs = getNextLoadPositionUs();
    boolean isBackedOff = currentLoadableException != null;
    boolean loadingOrBackedOff = loader.isLoading() || isBackedOff;

    // If we're not loading or backed off, evaluate the operation if (a) we don't have the next
    // chunk yet and we're not finished, or (b) if the last evaluation was over 2000ms ago.
    if (!loadingOrBackedOff && ((currentLoadableHolder.chunk == null && nextLoadPositionUs != -1)
        || (now - lastPerformedBufferOperation > 2000))) {
      // Perform the evaluation.
      lastPerformedBufferOperation = now;
      doChunkOperation();
      boolean chunksDiscarded = discardUpstreamMediaChunks(currentLoadableHolder.queueSize);
      // Update the next load position as appropriate.
      if (currentLoadableHolder.chunk == null) {
        // Set loadPosition to -1 to indicate that we don't have anything to load.
        nextLoadPositionUs = -1;
      } else if (chunksDiscarded) {
        // Chunks were discarded, so we need to re-evaluate the load position.
        nextLoadPositionUs = getNextLoadPositionUs();
      }
    }

    // Update the control with our current state, and determine whether we're the next loader.
    boolean nextLoader = loadControl.update(this, downstreamPositionUs, nextLoadPositionUs,
        loadingOrBackedOff);

    if (isBackedOff) {
      long elapsedMillis = now - currentLoadableExceptionTimestamp;
      if (elapsedMillis >= getRetryDelayMillis(currentLoadableExceptionCount)) {
        resumeFromBackOff();
      }
      return;
    }

    if (!loader.isLoading() && nextLoader) {
      maybeStartLoading();
    }
  }

  /**
   * Gets the next load time, assuming that the next load starts where the previous chunk ended (or
   * from the pending reset time, if there is one).
   */
  private long getNextLoadPositionUs() {
    if (isPendingReset()) {
      return pendingResetPositionUs;
    } else {
      return loadingFinished ? -1 : mediaChunks.getLast().endTimeUs;
    }
  }

  /**
   * Resumes loading.
   * <p>
   * If the {@link ChunkSource} returns a chunk equivalent to the backed off chunk B, then the
   * loading of B will be resumed. In all other cases B will be discarded and the new chunk will
   * be loaded.
   */
  private void resumeFromBackOff() {
    currentLoadableException = null;

    Chunk backedOffChunk = currentLoadableHolder.chunk;
    if (!isMediaChunk(backedOffChunk)) {
      doChunkOperation();
      discardUpstreamMediaChunks(currentLoadableHolder.queueSize);
      if (currentLoadableHolder.chunk == backedOffChunk) {
        // Chunk was unchanged. Resume loading.
        loader.startLoading(backedOffChunk, this);
      } else {
        // Chunk was changed. Notify that the existing load was canceled.
        notifyLoadCanceled(backedOffChunk.bytesLoaded());
        // Start loading the replacement.
        maybeStartLoading();
      }
      return;
    }

    if (backedOffChunk == mediaChunks.getFirst()) {
      // We're not able to clear the first media chunk, so we have no choice but to continue
      // loading it.
      loader.startLoading(backedOffChunk, this);
      return;
    }

    // The current loadable is the last media chunk. Remove it before we invoke the chunk source,
    // and add it back again afterwards.
    BaseMediaChunk removedChunk = mediaChunks.removeLast();
    Assertions.checkState(backedOffChunk == removedChunk);
    doChunkOperation();
    mediaChunks.add(removedChunk);

    if (currentLoadableHolder.chunk == backedOffChunk) {
      // Chunk was unchanged. Resume loading.
      loader.startLoading(backedOffChunk, this);
    } else {
      // Chunk was changed. Notify that the existing load was canceled.
      notifyLoadCanceled(backedOffChunk.bytesLoaded());
      // This call will remove and release at least one chunk from the end of mediaChunks. Since
      // the current loadable is the last media chunk, it is guaranteed to be removed.
      discardUpstreamMediaChunks(currentLoadableHolder.queueSize);
      clearCurrentLoadableException();
      maybeStartLoading();
    }
  }

  private void maybeStartLoading() {
    Chunk currentLoadable = currentLoadableHolder.chunk;
    if (currentLoadable == null) {
      // Nothing to load.
      return;
    }
    currentLoadStartTimeMs = SystemClock.elapsedRealtime();
    if (isMediaChunk(currentLoadable)) {
      BaseMediaChunk mediaChunk = (BaseMediaChunk) currentLoadable;
      mediaChunk.init(sampleQueue);
      mediaChunks.add(mediaChunk);
      if (isPendingReset()) {
        pendingResetPositionUs = NO_RESET_PENDING;
      }
      notifyLoadStarted(mediaChunk.dataSpec.length, mediaChunk.type, mediaChunk.trigger,
          mediaChunk.format, mediaChunk.startTimeUs, mediaChunk.endTimeUs);
    } else {
      notifyLoadStarted(currentLoadable.dataSpec.length, currentLoadable.type,
          currentLoadable.trigger, currentLoadable.format, -1, -1);
    }
    loader.startLoading(currentLoadable, this);
  }

  /**
   * Sets up the {@link #currentLoadableHolder}, passes it to the chunk source to cause it to be
   * updated with the next operation, and updates {@link #loadingFinished} if the end of the stream
   * is reached.
   */
  private void doChunkOperation() {
    currentLoadableHolder.endOfStream = false;
    currentLoadableHolder.queueSize = readOnlyMediaChunks.size();
    chunkSource.getChunkOperation(readOnlyMediaChunks,
        pendingResetPositionUs != NO_RESET_PENDING ? pendingResetPositionUs : downstreamPositionUs,
        currentLoadableHolder);
    loadingFinished = currentLoadableHolder.endOfStream;
  }

  /**
   * Discard upstream media chunks until the queue length is equal to the length specified.
   *
   * @param queueLength The desired length of the queue.
   * @return True if chunks were discarded. False otherwise.
   */
  private boolean discardUpstreamMediaChunks(int queueLength) {
    if (mediaChunks.size() <= queueLength) {
      return false;
    }
    long startTimeUs = 0;
    long endTimeUs = mediaChunks.getLast().endTimeUs;

    BaseMediaChunk removed = null;
    while (mediaChunks.size() > queueLength) {
      removed = mediaChunks.removeLast();
      startTimeUs = removed.startTimeUs;
      loadingFinished = false;
    }
    sampleQueue.discardUpstreamSamples(removed.getFirstSampleIndex());

    notifyUpstreamDiscarded(startTimeUs, endTimeUs);
    return true;
  }

  private boolean isMediaChunk(Chunk chunk) {
    return chunk instanceof BaseMediaChunk;
  }

  private boolean isPendingReset() {
    return pendingResetPositionUs != NO_RESET_PENDING;
  }

  private long getRetryDelayMillis(long errorCount) {
    return Math.min((errorCount - 1) * 1000, 5000);
  }

  protected final long usToMs(long timeUs) {
    return timeUs / 1000;
  }

  private void notifyLoadStarted(final long length, final int type, final int trigger,
      final Format format, final long mediaStartTimeUs, final long mediaEndTimeUs) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onLoadStarted(eventSourceId, length, type, trigger, format,
              usToMs(mediaStartTimeUs), usToMs(mediaEndTimeUs));
        }
      });
    }
  }

  private void notifyLoadCompleted(final long bytesLoaded, final int type, final int trigger,
      final Format format, final long mediaStartTimeUs, final long mediaEndTimeUs,
      final long elapsedRealtimeMs, final long loadDurationMs) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onLoadCompleted(eventSourceId, bytesLoaded, type, trigger, format,
              usToMs(mediaStartTimeUs), usToMs(mediaEndTimeUs), elapsedRealtimeMs, loadDurationMs);
        }
      });
    }
  }

  private void notifyLoadCanceled(final long bytesLoaded) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onLoadCanceled(eventSourceId, bytesLoaded);
        }
      });
    }
  }

  private void notifyLoadError(final IOException e) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onLoadError(eventSourceId, e);
        }
      });
    }
  }

  private void notifyUpstreamDiscarded(final long mediaStartTimeUs, final long mediaEndTimeUs) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onUpstreamDiscarded(eventSourceId, usToMs(mediaStartTimeUs),
              usToMs(mediaEndTimeUs));
        }
      });
    }
  }

  private void notifyDownstreamFormatChanged(final Format format, final int trigger,
      final long positionUs) {
    if (eventHandler != null && eventListener != null) {
      eventHandler.post(new Runnable()  {
        @Override
        public void run() {
          eventListener.onDownstreamFormatChanged(eventSourceId, format, trigger,
              usToMs(positionUs));
        }
      });
    }
  }

}
