package org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators;

import org.schabi.newpipe.extractor.MediaFormat;
import org.schabi.newpipe.extractor.NewPipe;
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.services.youtube.DeliveryType;
import org.schabi.newpipe.extractor.services.youtube.ItagItem;
import org.schabi.newpipe.extractor.utils.ManifestCreatorCache;
import org.w3c.dom.Attr;
import org.w3c.dom.DOMException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;

import javax.annotation.Nonnull;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;

import java.io.IOException;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;

import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.addYoutubeHeaders;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;

/**
 * Utilities and constants for YouTube DASH manifest creators.
 *
 * <p>
 * This class includes common methods of manifest creators and useful constants.
 * </p>
 *
 * <p>
 * Generation of DASH documents and their conversion as a string is done using external classes
 * from {@link org.w3c.dom} and {@link javax.xml} packages.
 * </p>
 */
public final class YoutubeDashManifestCreatorsUtils {

    private YoutubeDashManifestCreatorsUtils() {
    }

    /**
     * The redirect count limit that this class uses, which is the same limit as OkHttp.
     */
    public static final int MAXIMUM_REDIRECT_COUNT = 20;

    /**
     * URL parameter of the first sequence for live, post-live-DVR and OTF streams.
     */
    public static final String SQ_0 = "&sq=0";

    /**
     * URL parameter of the first stream request made by official clients.
     */
    public static final String RN_0 = "&rn=0";

    /**
     * URL parameter specific to web clients. When this param is added, if a redirection occurs,
     * the server will not redirect clients to the redirect URL. Instead, it will provide this URL
     * as the response body.
     */
    public static final String ALR_YES = "&alr=yes";

    // XML elements of DASH MPD manifests
    // see https://www.brendanlong.com/the-structure-of-an-mpeg-dash-mpd.html
    public static final String MPD = "MPD";
    public static final String PERIOD = "Period";
    public static final String ADAPTATION_SET = "AdaptationSet";
    public static final String ROLE = "Role";
    public static final String REPRESENTATION = "Representation";
    public static final String AUDIO_CHANNEL_CONFIGURATION = "AudioChannelConfiguration";
    public static final String SEGMENT_TEMPLATE = "SegmentTemplate";
    public static final String SEGMENT_TIMELINE = "SegmentTimeline";
    public static final String BASE_URL = "BaseURL";
    public static final String SEGMENT_BASE = "SegmentBase";
    public static final String INITIALIZATION = "Initialization";

    /**
     * Create an attribute with {@link Document#createAttribute(String)}, assign to it the provided
     * name and value, then add it to the provided element using {@link
     * Element#setAttributeNode(Attr)}.
     *
     * @param element element to which to add the created node
     * @param doc     document to use to create the attribute
     * @param name    name of the attribute
     * @param value   value of the attribute, will be set using {@link Attr#setValue(String)}
     */
    public static void setAttribute(final Element element,
                                    final Document doc,
                                    final String name,
                                    final String value) {
        final Attr attr = doc.createAttribute(name);
        attr.setValue(value);
        element.setAttributeNode(attr);
    }

    /**
     * Generate a {@link Document} with common manifest creator elements added to it.
     *
     * <p>
     * Those are:
     * <ul>
     *     <li>{@code MPD} (using {@link #generateDocumentAndMpdElement(long)});</li>
     *     <li>{@code Period} (using {@link #generatePeriodElement(Document)});</li>
     *     <li>{@code AdaptationSet} (using {@link #generateAdaptationSetElement(Document,
     *     ItagItem)});</li>
     *     <li>{@code Role} (using {@link #generateRoleElement(Document)});</li>
     *     <li>{@code Representation} (using {@link #generateRepresentationElement(Document,
     *     ItagItem)});</li>
     *     <li>and, for audio streams, {@code AudioChannelConfiguration} (using
     *     {@link #generateAudioChannelConfigurationElement(Document, ItagItem)}).</li>
     * </ul>
     * </p>
     *
     * @param itagItem the {@link ItagItem} associated to the stream, which must not be null
     * @param streamDuration the duration of the stream, in milliseconds
     * @return a {@link Document} with the common elements added in it
     */
    @Nonnull
    public static Document generateDocumentAndDoCommonElementsGeneration(
            @Nonnull final ItagItem itagItem,
            final long streamDuration) throws CreationException {
        final Document doc = generateDocumentAndMpdElement(streamDuration);

        generatePeriodElement(doc);
        generateAdaptationSetElement(doc, itagItem);
        generateRoleElement(doc);
        generateRepresentationElement(doc, itagItem);
        if (itagItem.itagType == ItagItem.ItagType.AUDIO) {
            generateAudioChannelConfigurationElement(doc, itagItem);
        }

        return doc;
    }

    /**
     * Create a {@link Document} instance and generate the {@code <MPD>} element of the manifest.
     *
     * <p>
     * The generated {@code <MPD>} element looks like the manifest returned into the player
     * response of videos:
     * </p>
     *
     * <p>
     * {@code <MPD xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     * xmlns="urn:mpeg:DASH:schema:MPD:2011"
     * xsi:schemaLocation="urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd" minBufferTime="PT1.500S"
     * profiles="urn:mpeg:dash:profile:isoff-main:2011" type="static"
     * mediaPresentationDuration="PT$duration$S">}
     * (where {@code $duration$} represents the duration in seconds (a number with 3 digits after
     * the decimal point)).
     * </p>
     *
     * @param duration the duration of the stream, in milliseconds
     * @return a {@link Document} instance which contains a {@code <MPD>} element
     */
    @Nonnull
    public static Document generateDocumentAndMpdElement(final long duration)
            throws CreationException {
        try {
            final Document doc = newDocument();

            final Element mpdElement = doc.createElement(MPD);
            doc.appendChild(mpdElement);

            setAttribute(mpdElement, doc, "xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
            setAttribute(mpdElement, doc, "xmlns", "urn:mpeg:DASH:schema:MPD:2011");
            setAttribute(mpdElement, doc, "xsi:schemaLocation",
                    "urn:mpeg:DASH:schema:MPD:2011 DASH-MPD.xsd");
            setAttribute(mpdElement, doc, "minBufferTime", "PT1.500S");
            setAttribute(mpdElement, doc, "profiles", "urn:mpeg:dash:profile:full:2011");
            setAttribute(mpdElement, doc, "type", "static");
            setAttribute(mpdElement, doc, "mediaPresentationDuration",
                    String.format(Locale.ENGLISH, "PT%.3fS", duration / 1000.0));

            return doc;
        } catch (final Exception e) {
            throw new CreationException(
                    "Could not generate the DASH manifest or append the MPD doc to it", e);
        }
    }

    /**
     * Generate the {@code <Period>} element, appended as a child of the {@code <MPD>} element.
     *
     * <p>
     * The {@code <MPD>} element needs to be generated before this element with
     * {@link #generateDocumentAndMpdElement(long)}.
     * </p>
     *
     * @param doc the {@link Document} on which the the {@code <Period>} element will be appended
     */
    public static void generatePeriodElement(@Nonnull final Document doc)
            throws CreationException {
        try {
            final Element mpdElement = (Element) doc.getElementsByTagName(MPD).item(0);
            final Element periodElement = doc.createElement(PERIOD);
            mpdElement.appendChild(periodElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(PERIOD, e);
        }
    }

    /**
     * Generate the {@code <AdaptationSet>} element, appended as a child of the {@code <Period>}
     * element.
     *
     * <p>
     * The {@code <Period>} element needs to be generated before this element with
     * {@link #generatePeriodElement(Document)}.
     * </p>
     *
     * @param doc the {@link Document} on which the {@code <Period>} element will be appended
     * @param itagItem the {@link ItagItem} corresponding to the stream, which must not be null
     */
    public static void generateAdaptationSetElement(@Nonnull final Document doc,
                                                    @Nonnull final ItagItem itagItem)
            throws CreationException {
        try {
            final Element periodElement = (Element) doc.getElementsByTagName(PERIOD)
                    .item(0);
            final Element adaptationSetElement = doc.createElement(ADAPTATION_SET);

            setAttribute(adaptationSetElement, doc, "id", "0");

            final MediaFormat mediaFormat = itagItem.getMediaFormat();
            if (mediaFormat == null || isNullOrEmpty(mediaFormat.getMimeType())) {
                throw CreationException.couldNotAddElement(ADAPTATION_SET,
                        "the MediaFormat or its mime type is null or empty");
            }

            setAttribute(adaptationSetElement, doc, "mimeType", mediaFormat.getMimeType());
            setAttribute(adaptationSetElement, doc, "subsegmentAlignment", "true");

            periodElement.appendChild(adaptationSetElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(ADAPTATION_SET, e);
        }
    }

    /**
     * Generate the {@code <Role>} element, appended as a child of the {@code <AdaptationSet>}
     * element.
     *
     * <p>
     * This element, with its attributes and values, is:
     * </p>
     *
     * <p>
     * {@code <Role schemeIdUri="urn:mpeg:DASH:role:2011" value="main"/>}
     * </p>
     *
     * <p>
     * The {@code <AdaptationSet>} element needs to be generated before this element with
     * {@link #generateAdaptationSetElement(Document, ItagItem)}).
     * </p>
     *
     * @param doc the {@link Document} on which the the {@code <Role>} element will be appended
     */
    public static void generateRoleElement(@Nonnull final Document doc)
            throws CreationException {
        try {
            final Element adaptationSetElement = (Element) doc.getElementsByTagName(
                    ADAPTATION_SET).item(0);
            final Element roleElement = doc.createElement(ROLE);

            setAttribute(roleElement, doc, "schemeIdUri", "urn:mpeg:DASH:role:2011");
            setAttribute(roleElement, doc, "value", "main");

            adaptationSetElement.appendChild(roleElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(ROLE, e);
        }
    }

    /**
     * Generate the {@code <Representation>} element, appended as a child of the
     * {@code <AdaptationSet>} element.
     *
     * <p>
     * The {@code <AdaptationSet>} element needs to be generated before this element with
     * {@link #generateAdaptationSetElement(Document, ItagItem)}).
     * </p>
     *
     * @param doc the {@link Document} on which the the {@code <SegmentTimeline>} element will be
     *            appended
     * @param itagItem the {@link ItagItem} to use, which must not be null
     */
    public static void generateRepresentationElement(@Nonnull final Document doc,
                                                     @Nonnull final ItagItem itagItem)
            throws CreationException {
        try {
            final Element adaptationSetElement = (Element) doc.getElementsByTagName(
                    ADAPTATION_SET).item(0);
            final Element representationElement = doc.createElement(REPRESENTATION);

            final int id = itagItem.id;
            if (id <= 0) {
                throw CreationException.couldNotAddElement(REPRESENTATION,
                        "the id of the ItagItem is <= 0");
            }
            setAttribute(representationElement, doc, "id", String.valueOf(id));

            final String codec = itagItem.getCodec();
            if (isNullOrEmpty(codec)) {
                throw CreationException.couldNotAddElement(ADAPTATION_SET,
                        "the codec value of the ItagItem is null or empty");
            }
            setAttribute(representationElement, doc, "codecs", codec);
            setAttribute(representationElement, doc, "startWithSAP", "1");
            setAttribute(representationElement, doc, "maxPlayoutRate", "1");

            final int bitrate = itagItem.getBitrate();
            if (bitrate <= 0) {
                throw CreationException.couldNotAddElement(REPRESENTATION,
                        "the bitrate of the ItagItem is <= 0");
            }
            setAttribute(representationElement, doc, "bandwidth", String.valueOf(bitrate));

            if (itagItem.itagType == ItagItem.ItagType.VIDEO
                    || itagItem.itagType == ItagItem.ItagType.VIDEO_ONLY) {
                final int height = itagItem.getHeight();
                final int width = itagItem.getWidth();
                if (height <= 0 && width <= 0) {
                    throw CreationException.couldNotAddElement(REPRESENTATION,
                            "both width and height of the ItagItem are <= 0");
                }

                if (width > 0) {
                    setAttribute(representationElement, doc, "width", String.valueOf(width));
                }
                setAttribute(representationElement, doc, "height",
                        String.valueOf(itagItem.getHeight()));

                final int fps = itagItem.getFps();
                if (fps > 0) {
                    setAttribute(representationElement, doc, "frameRate", String.valueOf(fps));
                }
            }

            if (itagItem.itagType == ItagItem.ItagType.AUDIO && itagItem.getSampleRate() > 0) {
                final Attr audioSamplingRateAttribute = doc.createAttribute(
                        "audioSamplingRate");
                audioSamplingRateAttribute.setValue(String.valueOf(itagItem.getSampleRate()));
            }

            adaptationSetElement.appendChild(representationElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(REPRESENTATION, e);
        }
    }

    /**
     * Generate the {@code <AudioChannelConfiguration>} element, appended as a child of the
     * {@code <Representation>} element.
     *
     * <p>
     * This method is only used when generating DASH manifests of audio streams.
     * </p>
     *
     * <p>
     * It will produce the following element:
     * <br>
     * {@code <AudioChannelConfiguration
     * schemeIdUri="urn:mpeg:dash:23003:3:audio_channel_configuration:2011"
     * value="audioChannelsValue"}
     * <br>
     * (where {@code audioChannelsValue} is get from the {@link ItagItem} passed as the second
     * parameter of this method)
     * </p>
     *
     * <p>
     * The {@code <Representation>} element needs to be generated before this element with
     * {@link #generateRepresentationElement(Document, ItagItem)}).
     * </p>
     *
     * @param doc the {@link Document} on which the {@code <AudioChannelConfiguration>} element will
     *            be appended
     * @param itagItem the {@link ItagItem} to use, which must not be null
     */
    public static void generateAudioChannelConfigurationElement(
            @Nonnull final Document doc,
            @Nonnull final ItagItem itagItem) throws CreationException {
        try {
            final Element representationElement = (Element) doc.getElementsByTagName(
                    REPRESENTATION).item(0);
            final Element audioChannelConfigurationElement = doc.createElement(
                    AUDIO_CHANNEL_CONFIGURATION);

            setAttribute(audioChannelConfigurationElement, doc, "schemeIdUri",
                    "urn:mpeg:dash:23003:3:audio_channel_configuration:2011");

//            if (itagItem.getAudioChannels() <= 0) {
//                throw new CreationException("the number of audioChannels in the ItagItem is <= 0: "
//                        + itagItem.getAudioChannels());
//            }
            setAttribute(audioChannelConfigurationElement, doc, "value",
                    String.valueOf(itagItem.getAudioChannels()));

            representationElement.appendChild(audioChannelConfigurationElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(AUDIO_CHANNEL_CONFIGURATION, e);
        }
    }

    /**
     * Convert a DASH manifest {@link Document doc} to a string and cache it.
     *
     * @param originalBaseStreamingUrl the original base URL of the stream
     * @param doc                      the doc to be converted
     * @param manifestCreatorCache     the {@link ManifestCreatorCache} on which store the string
     *                                 generated
     * @return the DASH manifest {@link Document doc} converted to a string
     */
    public static String buildAndCacheResult(
            @Nonnull final String originalBaseStreamingUrl,
            @Nonnull final Document doc,
            @Nonnull final ManifestCreatorCache<String, String> manifestCreatorCache)
            throws CreationException {

        try {
            final String documentXml = documentToXml(doc);
            manifestCreatorCache.put(originalBaseStreamingUrl, documentXml);
            return documentXml;
        } catch (final Exception e) {
            throw new CreationException(
                    "Could not convert the DASH manifest generated to a string", e);
        }
    }

    /**
     * Generate the {@code <SegmentTemplate>} element, appended as a child of the
     * {@code <Representation>} element.
     *
     * <p>
     * This method is only used when generating DASH manifests from OTF and post-live-DVR streams.
     * </p>
     *
     * <p>
     * It will produce a {@code <SegmentTemplate>} element with the following attributes:
     * <ul>
     *     <li>{@code startNumber}, which takes the value {@code 0} for post-live-DVR streams and
     *     {@code 1} for OTF streams;</li>
     *     <li>{@code timescale}, which is always {@code 1000};</li>
     *     <li>{@code media}, which is the base URL of the stream on which is appended
     *     {@code &sq=$Number$};</li>
     *     <li>{@code initialization} (only for OTF streams), which is the base URL of the stream
     *     on which is appended {@link #SQ_0}.</li>
     * </ul>
     * </p>
     *
     * <p>
     * The {@code <Representation>} element needs to be generated before this element with
     * {@link #generateRepresentationElement(Document, ItagItem)}).
     * </p>
     *
     * @param doc          the {@link Document} on which the {@code <SegmentTemplate>} element will
     *                     be appended
     * @param baseUrl      the base URL of the OTF/post-live-DVR stream
     * @param deliveryType the stream {@link DeliveryType delivery type}, which must be either
     * {@link DeliveryType#OTF OTF} or {@link DeliveryType#LIVE LIVE}
     */
    public static void generateSegmentTemplateElement(@Nonnull final Document doc,
                                                      @Nonnull final String baseUrl,
                                                      final DeliveryType deliveryType)
            throws CreationException {
        if (deliveryType != DeliveryType.OTF && deliveryType != DeliveryType.LIVE) {
            throw CreationException.couldNotAddElement(SEGMENT_TEMPLATE, "invalid delivery type: "
                    + deliveryType);
        }

        try {
            final Element representationElement = (Element) doc.getElementsByTagName(
                    REPRESENTATION).item(0);
            final Element segmentTemplateElement = doc.createElement(SEGMENT_TEMPLATE);

            // The first sequence of post DVR streams is the beginning of the video stream and not
            // an initialization segment
            setAttribute(segmentTemplateElement, doc, "startNumber",
                    deliveryType == DeliveryType.LIVE ? "0" : "1");
            setAttribute(segmentTemplateElement, doc, "timescale", "1000");

            // Post-live-DVR/ended livestreams streams don't require an initialization sequence
            if (deliveryType != DeliveryType.LIVE) {
                setAttribute(segmentTemplateElement, doc, "initialization", baseUrl + SQ_0);
            }

            setAttribute(segmentTemplateElement, doc, "media", baseUrl + "&sq=$Number$");

            representationElement.appendChild(segmentTemplateElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(SEGMENT_TEMPLATE, e);
        }
    }

    /**
     * Generate the {@code <SegmentTimeline>} element, appended as a child of the
     * {@code <SegmentTemplate>} element.
     *
     * <p>
     * The {@code <SegmentTemplate>} element needs to be generated before this element with
     * {@link #generateSegmentTemplateElement(Document, String, DeliveryType)}.
     * </p>
     *
     * @param doc the {@link Document} on which the the {@code <SegmentTimeline>} element will be
     *            appended
     */
    public static void generateSegmentTimelineElement(@Nonnull final Document doc)
            throws CreationException {
        try {
            final Element segmentTemplateElement = (Element) doc.getElementsByTagName(
                    SEGMENT_TEMPLATE).item(0);
            final Element segmentTimelineElement = doc.createElement(SEGMENT_TIMELINE);

            segmentTemplateElement.appendChild(segmentTimelineElement);
        } catch (final DOMException e) {
            throw CreationException.couldNotAddElement(SEGMENT_TIMELINE, e);
        }
    }

    /**
     * Get the "initialization" {@link Response response} of a stream.
     *
     * <p>This method fetches, for OTF streams and for post-live-DVR streams:
     *     <ul>
     *         <li>the base URL of the stream, to which are appended {@link #SQ_0} and
     *         {@link #RN_0} parameters, with a {@code GET} request for streaming URLs from HTML5
     *         clients and a {@code POST} request for the ones from the {@code ANDROID} and the
     *         {@code IOS} clients;</li>
     *         <li>for streaming URLs from HTML5 clients, the {@link #ALR_YES} param is also added.
     *         </li>
     *     </ul>
     * </p>
     *
     * @param baseStreamingUrl the base URL of the stream, which must not be null
     * @param itagItem         the {@link ItagItem} of stream, which must not be null
     * @param deliveryType     the {@link DeliveryType} of the stream
     * @return the "initialization" response, without redirections on the network on which the
     * request(s) is/are made
     */
    @Nonnull
    public static Response getInitializationResponse(@Nonnull String baseStreamingUrl,
                                                     @Nonnull final ItagItem itagItem,
                                                     final DeliveryType deliveryType)
            throws CreationException {
        final boolean isHtml5StreamingUrl = isWebStreamingUrl(baseStreamingUrl)
                || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(baseStreamingUrl);
        final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(baseStreamingUrl);
        final boolean isIosStreamingUrl = isIosStreamingUrl(baseStreamingUrl);
        if (isHtml5StreamingUrl) {
            baseStreamingUrl += ALR_YES;
        }
        baseStreamingUrl = appendRnSqParamsIfNeeded(baseStreamingUrl, deliveryType);

        final Downloader downloader = NewPipe.getDownloader();
        if (isHtml5StreamingUrl) {
            final String mimeTypeExpected = itagItem.getMediaFormat().getMimeType();
            if (!isNullOrEmpty(mimeTypeExpected)) {
                return getStreamingWebUrlWithoutRedirects(downloader, baseStreamingUrl,
                        mimeTypeExpected);
            }
        } else if (isAndroidStreamingUrl || isIosStreamingUrl) {
            try {
                final Map<String, List<String>> headers = new HashMap<>();
                headers.put("User-Agent", Collections.singletonList(
                        isAndroidStreamingUrl ? getAndroidUserAgent(null)
                                : getIosUserAgent(null)));
                final byte[] emptyBody = "".getBytes(StandardCharsets.UTF_8);
                return downloader.post(baseStreamingUrl, headers, emptyBody);
            } catch (final IOException | ExtractionException e) {
                throw new CreationException("Could not get the "
                        + (isIosStreamingUrl ? "ANDROID" : "IOS") + " streaming URL response", e);
            }
        }

        try {
            return downloader.get(baseStreamingUrl);
        } catch (final IOException | ExtractionException e) {
            throw new CreationException("Could not get the streaming URL response", e);
        }
    }

    /**
     * Generate a new {@link DocumentBuilder} secured from XXE attacks, on platforms which
     * support setting {@link XMLConstants#ACCESS_EXTERNAL_DTD} and
     * {@link XMLConstants#ACCESS_EXTERNAL_SCHEMA} in {@link DocumentBuilderFactory} instances.
     *
     * @return an instance of {@link Document} secured against XXE attacks on supported platforms,
     *         that should then be convertible to an XML string without security problems
     */
    private static Document newDocument() throws ParserConfigurationException {
        final DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
        try {
            documentBuilderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            documentBuilderFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        } catch (final Exception ignored) {
            // Ignore exceptions as setting these attributes to secure XML generation is not
            // supported by all platforms (like the Android implementation)
        }

        return documentBuilderFactory.newDocumentBuilder().newDocument();
    }

    /**
     * Generate a new {@link TransformerFactory} secured from XXE attacks, on platforms which
     * support setting {@link XMLConstants#ACCESS_EXTERNAL_DTD} and
     * {@link XMLConstants#ACCESS_EXTERNAL_SCHEMA} in {@link TransformerFactory} instances.
     *
     * @param doc the doc to convert, which must have been created using {@link #newDocument()} to
     *            properly prevent XXE attacks
     * @return the doc converted to an XML string, making sure there can't be XXE attacks
     */
    // Sonar warning is suppressed because it is still shown even if we apply its solution
    @SuppressWarnings("squid:S2755")
    private static String documentToXml(@Nonnull final Document doc)
            throws TransformerException {

        final TransformerFactory transformerFactory = TransformerFactory.newInstance();
        try {
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
            transformerFactory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
        } catch (final Exception ignored) {
            // Ignore exceptions as setting these attributes to secure XML generation is not
            // supported by all platforms (like the Android implementation)
        }

        final Transformer transformer = transformerFactory.newTransformer();
        transformer.setOutputProperty(OutputKeys.VERSION, "1.0");
        transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
        transformer.setOutputProperty(OutputKeys.STANDALONE, "no");

        final StringWriter result = new StringWriter();
        transformer.transform(new DOMSource(doc), new StreamResult(result));

        return result.toString();
    }

    /**
     * Append {@link #SQ_0} for post-live-DVR and OTF streams and {@link #RN_0} to all streams.
     *
     * @param baseStreamingUrl the base streaming URL to which the parameter(s) are being appended
     * @param deliveryType     the {@link DeliveryType} of the stream
     * @return the base streaming URL to which the param(s) are appended, depending on the
     * {@link DeliveryType} of the stream
     */
    @Nonnull
    private static String appendRnSqParamsIfNeeded(@Nonnull final String baseStreamingUrl,
                                                   @Nonnull final DeliveryType deliveryType) {
        return baseStreamingUrl + (deliveryType == DeliveryType.PROGRESSIVE ? "" : SQ_0) + RN_0;
    }

    /**
     * Get a URL on which no redirection between playback hosts should be present on the network
     * and/or IP used to fetch the streaming URL, for HTML5 clients.
     *
     * <p>This method will follow redirects which works in the following way:
     *     <ol>
     *         <li>the {@link #ALR_YES} param is appended to all streaming URLs</li>
     *         <li>if no redirection occurs, the video server will return the streaming data;</li>
     *         <li>if a redirection occurs, the server will respond with HTTP status code 200 and a
     *         {@code text/plain} mime type. The redirection URL is the response body;</li>
     *         <li>the redirection URL is requested and the steps above from step 2 are repeated,
     *         until too many redirects are reached of course (the maximum number of redirects is
     *         {@link #MAXIMUM_REDIRECT_COUNT the same as OkHttp}).</li>
     *     </ol>
     * </p>
     *
     * <p>
     * For non-HTML5 clients, redirections are managed in the standard way in
     * {@link #getInitializationResponse(String, ItagItem, DeliveryType)}.
     * </p>
     *
     * @param downloader               the {@link Downloader} instance to be used
     * @param streamingUrl             the streaming URL which we are trying to get a streaming URL
     *                                 without any redirection on the network and/or IP used
     * @param responseMimeTypeExpected the response mime type expected from Google video servers
     * @return the {@link Response} of the stream, which should have no redirections
     */
    @Nonnull
    private static Response getStreamingWebUrlWithoutRedirects(
            @Nonnull final Downloader downloader,
            @Nonnull String streamingUrl,
            @Nonnull final String responseMimeTypeExpected)
            throws CreationException {
        try {
            final Map<String, List<String>> headers = new HashMap<>();
            addYoutubeHeaders(headers);

            String responseMimeType = "";

            int redirectsCount = 0;
            while (!responseMimeType.equals(responseMimeTypeExpected)
                    && redirectsCount < MAXIMUM_REDIRECT_COUNT) {
                final Response response = downloader.get(streamingUrl, headers);

                final int responseCode = response.responseCode();
                if (responseCode != 200) {
                    throw new CreationException(
                            "Could not get the initialization URL: HTTP response code "
                                    + responseCode);
                }

                // A valid HTTP 1.0+ response should include a Content-Type header, so we can
                // require that the response from video servers has this header.
                responseMimeType = Objects.requireNonNull(response.getHeader("Content-Type"),
                        "Could not get the Content-Type header from the response headers");

                // The response body is the redirection URL
                if (responseMimeType.equals("text/plain")) {
                    streamingUrl = response.responseBody();
                    redirectsCount++;
                } else {
                    return response;
                }
            }

            if (redirectsCount >= MAXIMUM_REDIRECT_COUNT) {
                throw new CreationException(
                        "Too many redirects when trying to get the the streaming URL response of a "
                                + "HTML5 client");
            }

            // This should never be reached, but is required because we don't want to return null
            // here
            throw new CreationException(
                    "Could not get the streaming URL response of a HTML5 client: unreachable code "
                            + "reached!");
        } catch (final IOException | ExtractionException e) {
            throw new CreationException(
                    "Could not get the streaming URL response of a HTML5 client", e);
        }
    }
}
