package org.schabi.newpipe.extractor.services.peertube.extractors;

import static org.schabi.newpipe.extractor.utils.Utils.UTF_8;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;

import com.grack.nanojson.JsonArray;
import com.grack.nanojson.JsonObject;
import com.grack.nanojson.JsonParser;
import com.grack.nanojson.JsonParserException;

import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.linkhandler.LinkHandler;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.services.peertube.PeertubeParsingHelper;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeStreamLinkHandlerFactory;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.DeliveryMethod;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.StreamExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfoItemsCollector;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.extractor.utils.JsonUtils;
import org.schabi.newpipe.extractor.utils.Utils;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;

import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE;
import static org.schabi.newpipe.extractor.utils.Utils.EMPTY_STRING;

public class PeertubeStreamExtractor extends StreamExtractor {
    private static final String ACCOUNT_HOST = "account.host";
    private static final String ACCOUNT_NAME = "account.name";
    private static final String FILES = "files";
    private static final String FILE_DOWNLOAD_URL = "fileDownloadUrl";
    private static final String FILE_URL = "fileUrl";
    private static final String PLAYLIST_URL = "playlistUrl";
    private static final String RESOLUTION_ID = "resolution.id";
    private static final String STREAMING_PLAYLISTS = "streamingPlaylists";

    private final String baseUrl;
    private JsonObject json;

    private final List<SubtitlesStream> subtitles = new ArrayList<>();
    private final List<AudioStream> audioStreams = new ArrayList<>();
    private final List<VideoStream> videoStreams = new ArrayList<>();

    public PeertubeStreamExtractor(final StreamingService service, final LinkHandler linkHandler)
            throws ParsingException {
        super(service, linkHandler);
        this.baseUrl = getBaseUrl();
    }

    @Override
    public String getTextualUploadDate() throws ParsingException {
        return JsonUtils.getString(json, "publishedAt");
    }

    @Override
    public DateWrapper getUploadDate() throws ParsingException {
        final String textualUploadDate = getTextualUploadDate();

        if (textualUploadDate == null) {
            return null;
        }

        return new DateWrapper(PeertubeParsingHelper.parseDateFrom(textualUploadDate));
    }

    @Nonnull
    @Override
    public String getThumbnailUrl() throws ParsingException {
        return baseUrl + JsonUtils.getString(json, "previewPath");
    }

    @Nonnull
    @Override
    public Description getDescription() throws ParsingException {
        String text;
        try {
            text = JsonUtils.getString(json, "description");
        } catch (final ParsingException e) {
            return Description.EMPTY_DESCRIPTION;
        }
        if (text.length() == 250 && text.substring(247).equals("...")) {
            // If description is shortened, get full description
            final Downloader dl = NewPipe.getDownloader();
            try {
                final Response response = dl.get(baseUrl
                        + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT
                        + getId() + "/description");
                final JsonObject jsonObject = JsonParser.object().from(response.responseBody());
                text = JsonUtils.getString(jsonObject, "description");
            } catch (final IOException | ReCaptchaException | JsonParserException ignored) {
                // Something went wrong when getting the full description, use the shortened one
            }
        }
        return new Description(text, Description.MARKDOWN);
    }

    @Override
    public int getAgeLimit() throws ParsingException {
        final boolean isNSFW = JsonUtils.getBoolean(json, "nsfw");
        if (isNSFW) {
            return 18;
        } else {
            return NO_AGE_LIMIT;
        }
    }

    @Override
    public long getLength() {
        return json.getLong("duration");
    }

    @Override
    public long getTimeStamp() throws ParsingException {
        final long timestamp = getTimestampSeconds(
                "((#|&|\\?)start=\\d{0,3}h?\\d{0,3}m?\\d{1,3}s?)");

        if (timestamp == -2) {
            // regex for timestamp was not found
            return 0;
        } else {
            return timestamp;
        }
    }

    @Override
    public long getViewCount() {
        return json.getLong("views");
    }

    @Override
    public long getLikeCount() {
        return json.getLong("likes");
    }

    @Override
    public long getDislikeCount() {
        return json.getLong("dislikes");
    }

    @Nonnull
    @Override
    public String getUploaderUrl() throws ParsingException {
        final String name = JsonUtils.getString(json, ACCOUNT_NAME);
        final String host = JsonUtils.getString(json, ACCOUNT_HOST);
        return getService().getChannelLHFactory().fromId("accounts/" + name + "@" + host, baseUrl)
                .getUrl();
    }

    @Nonnull
    @Override
    public String getUploaderName() throws ParsingException {
        return JsonUtils.getString(json, "account.displayName");
    }

    @Nonnull
    @Override
    public String getUploaderAvatarUrl() {
        String value;
        try {
            value = JsonUtils.getString(json, "account.avatar.path");
        } catch (final Exception e) {
            value = "/client/assets/images/default-avatar.png";
        }
        return baseUrl + value;
    }

    @Nonnull
    @Override
    public String getSubChannelUrl() throws ParsingException {
        return JsonUtils.getString(json, "channel.url");
    }

    @Nonnull
    @Override
    public String getSubChannelName() throws ParsingException {
        return JsonUtils.getString(json, "channel.displayName");
    }

    @Nonnull
    @Override
    public String getSubChannelAvatarUrl() {
        String value;
        try {
            value = JsonUtils.getString(json, "channel.avatar.path");
        } catch (final Exception e) {
            value = "/client/assets/images/default-avatar.png";
        }
        return baseUrl + value;
    }

    @Nonnull
    @Override
    public String getHlsUrl() {
        assertPageFetched();

        if (getStreamType() == StreamType.VIDEO_STREAM
                && !isNullOrEmpty(json.getObject(FILES))) {
            return json.getObject(FILES).getString(PLAYLIST_URL, EMPTY_STRING);
        }

        return json.getArray(STREAMING_PLAYLISTS).getObject(0).getString(PLAYLIST_URL,
                EMPTY_STRING);
    }

    @Override
    public List<AudioStream> getAudioStreams() throws ParsingException {
        assertPageFetched();

        /*
        Some videos have audio streams; others don't.
        So an audio stream may be available if a video stream is available.
        Audio streams are also not returned as separated streams for livestreams.
        That's why the extraction of audio streams is only run when there are video streams
        extracted and when the content is not a livestream.
         */
        if (audioStreams.isEmpty() && videoStreams.isEmpty()
                && getStreamType() == StreamType.VIDEO_STREAM) {
            getStreams();
        }

        return audioStreams;
    }

    @Override
    public List<VideoStream> getVideoStreams() throws ExtractionException {
        assertPageFetched();

        if (videoStreams.isEmpty()) {
            if (getStreamType() == StreamType.VIDEO_STREAM) {
                getStreams();
            } else {
                extractLiveVideoStreams();
            }
        }

        return videoStreams;
    }

    @Override
    public List<VideoStream> getVideoOnlyStreams() {
        return Collections.emptyList();
    }

    @Nonnull
    @Override
    public List<SubtitlesStream> getSubtitlesDefault() {
        return subtitles;
    }

    @Nonnull
    @Override
    public List<SubtitlesStream> getSubtitles(final MediaFormat format) {
        return subtitles.stream()
                .filter(sub -> sub.getFormat() == format)
                .collect(Collectors.toList());
    }

    @Override
    public StreamType getStreamType() {
        return json.getBoolean("isLive") ? StreamType.LIVE_STREAM : StreamType.VIDEO_STREAM;
    }

    @Nullable
    @Override
    public StreamInfoItemsCollector getRelatedItems() throws IOException, ExtractionException {
        final List<String> tags = getTags();
        final String apiUrl;
        if (tags.isEmpty()) {
            apiUrl = baseUrl + "/api/v1/accounts/" + JsonUtils.getString(json, ACCOUNT_NAME)
                    + "@" + JsonUtils.getString(json, ACCOUNT_HOST)
                    + "/videos?start=0&count=8";
        } else {
            apiUrl = getRelatedItemsUrl(tags);
        }

        if (Utils.isBlank(apiUrl)) {
            return null;
        } else {
            final StreamInfoItemsCollector collector = new StreamInfoItemsCollector(
                    getServiceId());
            getStreamsFromApi(collector, apiUrl);
            return collector;
        }
    }

    @Nonnull
    @Override
    public List<String> getTags() {
        return JsonUtils.getStringListFromJsonArray(json.getArray("tags"));
    }

    @Nonnull
    @Override
    public String getSupportInfo() {
        try {
            return JsonUtils.getString(json, "support");
        } catch (final ParsingException e) {
            return EMPTY_STRING;
        }
    }

    @Nonnull
    private String getRelatedItemsUrl(@Nonnull final List<String> tags)
            throws UnsupportedEncodingException {
        final String url = baseUrl + PeertubeSearchQueryHandlerFactory.SEARCH_ENDPOINT;
        final StringBuilder params = new StringBuilder();
        params.append("start=0&count=8&sort=-createdAt");
        for (final String tag : tags) {
            params.append("&tagsOneOf=");
            params.append(URLEncoder.encode(tag, UTF_8));
        }
        return url + "?" + params;
    }

    private void getStreamsFromApi(final StreamInfoItemsCollector collector, final String apiUrl)
            throws IOException, ReCaptchaException, ParsingException {
        final Response response = getDownloader().get(apiUrl);
        JsonObject relatedVideosJson = null;
        if (response != null && !Utils.isBlank(response.responseBody())) {
            try {
                relatedVideosJson = JsonParser.object().from(response.responseBody());
            } catch (final JsonParserException e) {
                throw new ParsingException("Could not parse json data for related videos", e);
            }
        }

        if (relatedVideosJson != null) {
            collectStreamsFrom(collector, relatedVideosJson);
        }
    }

    private void collectStreamsFrom(final StreamInfoItemsCollector collector,
                                    final JsonObject jsonObject) throws ParsingException {
        final JsonArray contents;
        try {
            contents = (JsonArray) JsonUtils.getValue(jsonObject, "data");
        } catch (final Exception e) {
            throw new ParsingException("Could not extract related videos", e);
        }

        for (final Object c : contents) {
            if (c instanceof JsonObject) {
                final JsonObject item = (JsonObject) c;
                final PeertubeStreamInfoItemExtractor extractor =
                        new PeertubeStreamInfoItemExtractor(item, baseUrl);
                // Do not add the same stream in related streams
                if (!extractor.getUrl().equals(getUrl())) {
                    collector.commit(extractor);
                }
            }
        }
    }

    @Override
    public void onFetchPage(@Nonnull final Downloader downloader)
            throws IOException, ExtractionException {
        final Response response = downloader.get(
                baseUrl + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT + getId());
        if (response != null) {
            setInitialData(response.responseBody());
        } else {
            throw new ExtractionException("Could not extract PeerTube channel data");
        }

        loadSubtitles();
    }

    private void setInitialData(final String responseBody) throws ExtractionException {
        try {
            json = JsonParser.object().from(responseBody);
        } catch (final JsonParserException e) {
            throw new ExtractionException("Could not extract PeerTube stream data", e);
        }
        if (json == null) {
            throw new ExtractionException("Could not extract PeerTube stream data");
        }
        PeertubeParsingHelper.validate(json);
    }

    private void loadSubtitles() {
        if (subtitles.isEmpty()) {
            try {
                final Response response = getDownloader().get(baseUrl
                        + PeertubeStreamLinkHandlerFactory.VIDEO_API_ENDPOINT
                        + getId() + "/captions");
                final JsonObject captionsJson = JsonParser.object().from(response.responseBody());
                final JsonArray captions = JsonUtils.getArray(captionsJson, "data");
                for (final Object c : captions) {
                    if (c instanceof JsonObject) {
                        final JsonObject caption = (JsonObject) c;
                        final String url = baseUrl + JsonUtils.getString(caption, "captionPath");
                        final String languageCode = JsonUtils.getString(caption, "language.id");
                        final String ext = url.substring(url.lastIndexOf(".") + 1);
                        final MediaFormat fmt = MediaFormat.getFromSuffix(ext);
                        if (fmt != null && !isNullOrEmpty(languageCode)) {
                            subtitles.add(new SubtitlesStream.Builder()
                                    .setContent(url, true)
                                    .setMediaFormat(fmt)
                                    .setLanguageCode(languageCode)
                                    .setAutoGenerated(false)
                                    .build());
                        }
                    }
                }
            } catch (final Exception ignored) {
                // Ignore all exceptions
            }
        }
    }

    private void extractLiveVideoStreams() throws ParsingException {
        try {
            final JsonArray streamingPlaylists = json.getArray(STREAMING_PLAYLISTS);
            streamingPlaylists.stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .map(stream -> new VideoStream.Builder()
                            .setId(String.valueOf(stream.getInt("id", -1)))
                            .setContent(stream.getString(PLAYLIST_URL, EMPTY_STRING), true)
                            .setIsVideoOnly(false)
                            .setResolution(EMPTY_STRING)
                            .setMediaFormat(MediaFormat.MPEG_4)
                            .setDeliveryMethod(DeliveryMethod.HLS)
                            .build())
                    // Don't use the containsSimilarStream method because it will always return
                    // false so if there are multiples HLS URLs returned, only the first will be
                    // extracted in this case.
                    .forEachOrdered(videoStreams::add);
        } catch (final Exception e) {
            throw new ParsingException("Could not get video streams", e);
        }
    }

    private void getStreams() throws ParsingException {
        // Progressive streams
        getStreamsFromArray(json.getArray(FILES), EMPTY_STRING);

        // HLS streams
        try {
            for (final JsonObject playlist : json.getArray(STREAMING_PLAYLISTS).stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .collect(Collectors.toList())) {
                getStreamsFromArray(playlist.getArray(FILES), playlist.getString(PLAYLIST_URL));
            }
        } catch (final Exception e) {
            throw new ParsingException("Could not get streams", e);
        }
    }

    private void getStreamsFromArray(@Nonnull final JsonArray streams,
                                     final String playlistUrl) throws ParsingException {
        try {
            /*
            Starting with version 3.4.0 of PeerTube, the HLS playlist of stream resolutions
            contains the UUID of the streams, so we can't use the same method to get the URL of
            the HLS playlist without fetching the master playlist.
            These UUIDs are the same as the ones returned into the fileUrl and fileDownloadUrl
            strings.
            */
            final boolean isInstanceUsingRandomUuidsForHlsStreams = !isNullOrEmpty(playlistUrl)
                    && playlistUrl.endsWith("-master.m3u8");

            for (final JsonObject stream : streams.stream()
                    .filter(JsonObject.class::isInstance)
                    .map(JsonObject.class::cast)
                    .collect(Collectors.toList())) {

                // Extract stream version of streams first
                final String url = JsonUtils.getString(stream,
                        stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL);
                if (isNullOrEmpty(url)) {
                    // Not a valid stream URL
                    return;
                }

                final String resolution = JsonUtils.getString(stream, "resolution.label");
                final String idSuffix = stream.has(FILE_URL) ? FILE_URL : FILE_DOWNLOAD_URL;

                if (resolution.toLowerCase().contains("audio")) {
                    // An audio stream
                    addNewAudioStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
                            idSuffix, url, playlistUrl);
                } else {
                    // A video stream
                    addNewVideoStream(stream, isInstanceUsingRandomUuidsForHlsStreams, resolution,
                            idSuffix, url, playlistUrl);
                }
            }
        } catch (final Exception e) {
            throw new ParsingException("Could not get streams from array", e);
        }
    }

    @Nonnull
    private String getHlsPlaylistUrlFromFragmentedFileUrl(
            @Nonnull final JsonObject streamJsonObject,
            @Nonnull final String idSuffix,
            @Nonnull final String format,
            @Nonnull final String url) throws ParsingException {
        final String streamUrl = FILE_DOWNLOAD_URL.equals(idSuffix)
                ? JsonUtils.getString(streamJsonObject, FILE_URL)
                : url;
        return streamUrl.replace("-fragmented." + format, ".m3u8");
    }

    @Nonnull
    private String getHlsPlaylistUrlFromMasterPlaylist(@Nonnull final JsonObject streamJsonObject,
                                                       @Nonnull final String playlistUrl)
            throws ParsingException {
        return playlistUrl.replace("master", JsonUtils.getNumber(streamJsonObject,
                RESOLUTION_ID).toString());
    }

    private void addNewAudioStream(@Nonnull final JsonObject streamJsonObject,
                                   final boolean isInstanceUsingRandomUuidsForHlsStreams,
                                   @Nonnull final String resolution,
                                   @Nonnull final String idSuffix,
                                   @Nonnull final String url,
                                   @Nullable final String playlistUrl) throws ParsingException {
        final String extension = url.substring(url.lastIndexOf(".") + 1);
        final MediaFormat format = MediaFormat.getFromSuffix(extension);
        final String id = resolution + "-" + extension;

        // Add progressive HTTP streams first
        audioStreams.add(new AudioStream.Builder()
                .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
                .setContent(url, true)
                .setMediaFormat(format)
                .setAverageBitrate(UNKNOWN_BITRATE)
                .build());

        // Then add HLS streams
        if (!isNullOrEmpty(playlistUrl)) {
            final String hlsStreamUrl;
            if (isInstanceUsingRandomUuidsForHlsStreams) {
                hlsStreamUrl = getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix,
                        extension, url);

            } else {
                hlsStreamUrl = getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);
            }
            final AudioStream audioStream = new AudioStream.Builder()
                    .setId(id + "-" + DeliveryMethod.HLS)
                    .setContent(hlsStreamUrl, true)
                    .setDeliveryMethod(DeliveryMethod.HLS)
                    .setMediaFormat(format)
                    .setAverageBitrate(UNKNOWN_BITRATE)
                    .setManifestUrl(playlistUrl)
                    .build();
            if (!Stream.containSimilarStream(audioStream, audioStreams)) {
                audioStreams.add(audioStream);
            }
        }

        // Finally, add torrent URLs
        final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
        if (!isNullOrEmpty(torrentUrl)) {
            audioStreams.add(new AudioStream.Builder()
                    .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
                    .setContent(torrentUrl, true)
                    .setDeliveryMethod(DeliveryMethod.TORRENT)
                    .setMediaFormat(format)
                    .setAverageBitrate(UNKNOWN_BITRATE)
                    .build());
        }
    }

    private void addNewVideoStream(@Nonnull final JsonObject streamJsonObject,
                                   final boolean isInstanceUsingRandomUuidsForHlsStreams,
                                   @Nonnull final String resolution,
                                   @Nonnull final String idSuffix,
                                   @Nonnull final String url,
                                   @Nullable final String playlistUrl) throws ParsingException {
        final String extension = url.substring(url.lastIndexOf(".") + 1);
        final MediaFormat format = MediaFormat.getFromSuffix(extension);
        final String id = resolution + "-" + extension;

        // Add progressive HTTP streams first
        videoStreams.add(new VideoStream.Builder()
                .setId(id + "-" + idSuffix + "-" + DeliveryMethod.PROGRESSIVE_HTTP)
                .setContent(url, true)
                .setIsVideoOnly(false)
                .setResolution(resolution)
                .setMediaFormat(format)
                .build());

        // Then add HLS streams
        if (!isNullOrEmpty(playlistUrl)) {
            final String hlsStreamUrl = isInstanceUsingRandomUuidsForHlsStreams
                    ? getHlsPlaylistUrlFromFragmentedFileUrl(streamJsonObject, idSuffix, extension,
                            url)
                    : getHlsPlaylistUrlFromMasterPlaylist(streamJsonObject, playlistUrl);

            final VideoStream videoStream = new VideoStream.Builder()
                    .setId(id + "-" + DeliveryMethod.HLS)
                    .setContent(hlsStreamUrl, true)
                    .setIsVideoOnly(false)
                    .setDeliveryMethod(DeliveryMethod.HLS)
                    .setResolution(resolution)
                    .setMediaFormat(format)
                    .setManifestUrl(playlistUrl)
                    .build();
            if (!Stream.containSimilarStream(videoStream, videoStreams)) {
                videoStreams.add(videoStream);
            }
        }

        // Add finally torrent URLs
        final String torrentUrl = JsonUtils.getString(streamJsonObject, "torrentUrl");
        if (!isNullOrEmpty(torrentUrl)) {
            videoStreams.add(new VideoStream.Builder()
                    .setId(id + "-" + idSuffix + "-" + DeliveryMethod.TORRENT)
                    .setContent(torrentUrl, true)
                    .setIsVideoOnly(false)
                    .setDeliveryMethod(DeliveryMethod.TORRENT)
                    .setResolution(resolution)
                    .setMediaFormat(format)
                    .build());
        }
    }

    @Nonnull
    @Override
    public String getName() throws ParsingException {
        return JsonUtils.getString(json, "name");
    }

    @Nonnull
    @Override
    public String getHost() throws ParsingException {
        return JsonUtils.getString(json, ACCOUNT_HOST);
    }

    @Nonnull
    @Override
    public Privacy getPrivacy() {
        switch (json.getObject("privacy").getInt("id")) {
            case 1:
                return Privacy.PUBLIC;
            case 2:
                return Privacy.UNLISTED;
            case 3:
                return Privacy.PRIVATE;
            case 4:
                return Privacy.INTERNAL;
            default:
                return Privacy.OTHER;
        }
    }

    @Nonnull
    @Override
    public String getCategory() throws ParsingException {
        return JsonUtils.getString(json, "category.label");
    }

    @Nonnull
    @Override
    public String getLicence() throws ParsingException {
        return JsonUtils.getString(json, "licence.label");
    }

    @Override
    public Locale getLanguageInfo() {
        try {
            return new Locale(JsonUtils.getString(json, "language.id"));
        } catch (final ParsingException e) {
            return null;
        }
    }
}
