//  
//   Rebex Sample Code License
// 
//   Copyright 2026, REBEX CR s.r.o.
//   All rights reserved.
//   https://www.rebex.net/
// 
//   Permission to use, copy, modify, and/or distribute this software for any
//   purpose with or without fee is hereby granted.
// 
//   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
//   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
//   OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
//   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
//   HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
//   WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
//   FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
//   OTHER DEALINGS IN THE SOFTWARE.
// 

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
using Rebex.Net;

namespace Rebex.Samples
{
    /// <summary>
    /// Implements very simple but very fast HTTP/HTTPS downloader.
    /// </summary>
    public class HttpDownloader
    {
        // Default buffer size for the Receive operation.
        private const int RECEIVE_BUFFER_SIZE_IN_BYTES = 16384;

        // Value used for the unknown HTTP content length.
        private const string UNKNOWN_CONTENT_LENGTH_STRING = "--";

        // Simple template for HTTP GET verb.
        private const string HTTP_GET_COMMAND_TEMPLATE = "GET {0} HTTP/1.0\r\nHost: {1}\r\n{2}Connection: close\r\n\r\n";

        // Simple template for HTTP HEAD verb.
        private const string HTTP_HEAD_COMMAND_TEMPLATE = "HEAD {0} HTTP/1.0\r\n\r\n";

        // Default timeout for IO (Read/Write) operations (30 seconds).
        private const int IO_TIMEOUT_IN_MS = 30000;

        // Exception message used when the maximum allowed number of the HTTP redirects has been exceeded.
        private const string NUMBER_OF_ALLOWED_REDIRECTS_EXCEEDED_ERROR = "Number of allowed redirects exceeded.";

        // Exception message used when the server sends invalid location HTTP header.
        private const string EXPECTED_VALID_LOCATION_HTTP_HEADER_ERROR = "Expected valid location http header.";

        // How often the program should report download progress to console.
        // This value has no effect when the program runs in quiet ('-q' command line option) mode.
        private const int REPORT_DOWNLOAD_PROGRESS_INTERVAL_IN_MILLISECONDS = 100;

        // Determines whether the application writes messages to the console or is silent (determined by '-q' command line option)
        private bool _quietMode;

        /// <summary>
        /// Downloads HTTP URLs specified by options.
        /// </summary>
        /// <param name="options">Parsed command line options.</param>
        /// <returns>Exit code for the application.</returns>
        public async Task<int> DownloadUrls(HttpDownloaderOptions options)
        {
            if (options == null)
            {
                throw new ArgumentNullException(nameof(options));
            }

            // If -q (be quite) option is present, turn off logging.
            _quietMode = options.QuietMode;

            // Try to download resource from the 'main' URL.
            // For example: In the command line 'HttpsGet https://test.rebex.net -q -i "c:\download.txt"',
            // the main URL is the string 'https://test.rebex.net'.
            if (!string.IsNullOrEmpty(options.Url))
            {
                // Initiate download of the resource.
                await DownloadUrl(new Uri(options.Url), options, canRenameFile: true).ConfigureAwait(false);
            }

            // If option -i (see below) is not present, we are done.
            if (string.IsNullOrEmpty(options.InputFileWithUrlsPath))
            {
                return 0;
            }

            // Try to download all resources from the file specified by the -i option on the command line.
            // For example: Options parsed from the command line 'HttpsGet https://test.rebex.net -q -i "c:\download.txt"'
            // will have value "c:\download.txt" in the InputFileWithUrlsPath property.
            foreach (string url in ReadUrlsFromFile(options.InputFileWithUrlsPath))
            {
                // Initiate download of the resource.
                await DownloadUrl(new Uri(url), options, canRenameFile: false).ConfigureAwait(false);
            }

            return 0;
        }

        /// <summary>
        /// Enumerate all URLs in the file on the <param name="path"/>.
        /// </summary>
        /// <param name="path">Path to the file.</param>
        /// <returns>All URLs in the file <param name="path"/>.</returns>
        private IEnumerable<string> ReadUrlsFromFile(string path)
        {
            // Use lazy evaluation and assume that the file uses UTF-8 encoding.
            return File.ReadLines(path, Encoding.UTF8);
        }

        /// <summary>
        /// Method prepares download of the resource to the file.
        /// </summary>
        /// <param name="url">URL of the resource which will be downloaded.</param>
        /// <param name="options">Parsed command line options.</param>
        /// <param name="canRenameFile">When the value is true, the resulting file
        /// with the resource downloaded from the URL may use name from the value
        /// of the -O command line option (if the option is present).
        /// Otherwise create file name.</param>
        /// <returns>A Task that represents an asynchronous operation.</returns>
        private async Task DownloadUrl(Uri url, HttpDownloaderOptions options, bool canRenameFile)
        {
            // Maximum allowed number of redirects when downloading the resource.
            const int MAX_ALLOWED_REDIRECTS = 10;

            WriteLine(string.Empty);

            // Track the number of HTTP redirects.
            int remainingRedirects = MAX_ALLOWED_REDIRECTS;

            // Collection that stores informational messages with download progress.
            var downloadProgressDataCollection = new BlockingCollection<DownloadProgressInfo>();

            // Variable for a task that will process messages from DownloadProgressDataCollection.
            Task dumpMessagesTask = null;

            // Variable that implements IProgress<DownloadProgressData> interface.
            ConsoleDownloadProgress consoleDownloadDataProgressInfo = null;

            // Variable that will contain the last HTTP response.
            SimpleHttpResponse httpResponse = null;

            // Variable that will be true if an exception has been thrown.
            bool exceptionThrown = false;

            // Target file path.
            string targetFilePath = GetTargetFilePath(url, options, canRenameFile);

            // Extract file name from the target file path.
            string targetFileName = Path.GetFileName(targetFilePath);

            // Number of downloaded bytes.
            long totalDownloadedContentBytes = 0;

            try
            {
                // If -q option is not present.
                if (!options.QuietMode)
                {
                    // Create our implementation of the IProgress<DownloadProgressData> interface.
                    consoleDownloadDataProgressInfo = new ConsoleDownloadProgress(downloadProgressDataCollection);

                    // Process and write the progress messages to the console in the separate Task.
                    // The 'download resource' logic quickly publish information about the operation progress to the downloadProgressDataCollection,
                    // so the download itself will not be blocked by the "slow" processing of the progress messages and writing the results to the console.
                    // In other words, progress logging task and download task run in parallel.
                    dumpMessagesTask = Task.Run((Action)consoleDownloadDataProgressInfo.WriteCollection);
                }

                // Try to download the resource from the URL.
                Uri currentUrl = url;

                // Open target file for writing.
                using (Stream targetFileStream = GetTargetStream(targetFilePath, options))
                {
                    // Handle HTTP redirects in a do-while cycle.
                    do
                    {
                        // If the HTTP resource is redirected, we have previous SimpleHttpResponse.
                        if (httpResponse != null)
                        {
                            Debug.Assert(httpResponse.IsRedirectResponse);

                            // If the redirects counter reaches zero, report an error.
                            if (remainingRedirects == 0)
                            {
                                throw new HttpException(httpResponse.StatusCode, NUMBER_OF_ALLOWED_REDIRECTS_EXCEEDED_ERROR);
                            }

                            // Otherwise the HTTP redirect is allowed, but don't forget to decrement our redirects counter.
                            remainingRedirects--;

                            // Check the value of HTTP header location. If the location is not valid, throw HttpException.
                            if (string.IsNullOrWhiteSpace(httpResponse.Location))
                            {
                                throw new HttpException(httpResponse.StatusCode, EXPECTED_VALID_LOCATION_HTTP_HEADER_ERROR);
                            }

                            WriteLine($"Location: {httpResponse.Location} [following]");

                            // Update the current URL with the new location.
                            // IMPORTANT: Application used in production should perform thorough validation of the location header
                            // and must be able to distinguish between absolute and relative values.
                            // For the purposes of this sample, we naively assume that the location header contains an absolute URL.
                            currentUrl = new Uri(httpResponse.Location);
                        }

                        // Download the resource from the current URL.
                        // DownloadUrlCore method returns a Tuple with last SimpleHttpResponse (if exists) and a number of downloaded content bytes.
                        var result = await DownloadUrlCore(currentUrl, targetFileName, targetFileStream, options, consoleDownloadDataProgressInfo).ConfigureAwait(false);
                        httpResponse = result.HttpResponse;
                        totalDownloadedContentBytes = result.TotalDownloadedContentBytes;

                        // If the returned httpResponse is not null and HTTP redirect is required, go to the next iteration.
                    } while (httpResponse?.IsRedirectResponse ?? false);
                }
            }
            catch (Exception)
            {
                // IMPORTANT: For applications to be used in production, use more robust error handling.
                exceptionThrown = true;
                throw;
            }
            finally
            {
                // Notify BlockingCollection that we are done.
                downloadProgressDataCollection.CompleteAdding();

                // Wait for the task that processes progress info from DownloadProgressDataCollection method.
                if (dumpMessagesTask != null)
                {
                    await dumpMessagesTask.ConfigureAwait(false);
                }

                // If the resource has been successfully downloaded, write summary to the console.
                if (!exceptionThrown && !options.SpiderMode && httpResponse != null && httpResponse.IsOkResponse)
                {
                    string contentLength = httpResponse.ContentLength?.ToString() ?? UNKNOWN_CONTENT_LENGTH_STRING;
                    WriteLine(string.Empty);
                    WriteLine($"{DateTime.Now:yyyy-MM-dd HH:mm:ss} - '{targetFileName}' saved [{totalDownloadedContentBytes}/{contentLength}]");
                }
            }
        }

        /// <summary>
        /// Helper method that returns a writable <see cref="Stream"/> for the specified <param name="targetFilePath"/>,
        /// or <see cref="Stream.Null" /> if the -spider command line option was specified.
        /// </summary>
        /// <param name="targetFilePath">Target file path.</param>
        /// <param name="options">Parsed command line options.</param>
        /// <returns>
        /// Writable <see cref="Stream"/> for the <param name="targetFilePath">,
        /// or <see cref="Stream.Null" /> if the -spider command line option was specified.
        /// </returns>
        private static Stream GetTargetStream(string targetFilePath, HttpDownloaderOptions options)
        {
            if (options.SpiderMode)
            {
                return Stream.Null;
            }
            else
            {
                return new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.None, RECEIVE_BUFFER_SIZE_IN_BYTES, FileOptions.Asynchronous);
            }
        }

        /// <summary>
        /// Helper method that returns the target file path where the resource downloaded from the <param name="url" /> should be stored.
        /// </summary>
        /// <param name="url">URL of the resource.</param>
        /// <param name="options">Parsed command line options.</param>
        /// <param name="canRenameFile">When the value is true, the resulting file with the resource downloaded
        /// from the URL may use the value of the -O command line option as a file name (-O option is mapped
        /// to options.OutputFile property).</param>
        /// <returns>Target file path where the resource downloaded from the <param name="url" /> should be stored.</returns>
        private static string GetTargetFilePath(Uri url, HttpDownloaderOptions options, bool canRenameFile)
        {
            // If the value in the options.OutputFile property is present and allowed, use it;
            // otherwise try to use the last URL segment as the file name.
            bool hasOutputFileOption = !string.IsNullOrEmpty(options.OutputFile);
            bool useOutputFileOption = hasOutputFileOption && canRenameFile;
            string fileNameCandidate = useOutputFileOption
                ? options.OutputFile
                : url.Segments.LastOrDefault();

            if (!useOutputFileOption)
            {
                // If the file name is empty, generate a random name.
                if (string.IsNullOrWhiteSpace(fileNameCandidate) || fileNameCandidate.EndsWith("/"))
                {
                    fileNameCandidate = Path.GetRandomFileName();
                }
                else
                {
                    // If the file name is invalid, sanitize it.
                    var invalidChars = fileNameCandidate.Intersect(Path.GetInvalidFileNameChars());
                    if (invalidChars.Any())
                    {
                        foreach (var ch in invalidChars)
                        {
                            fileNameCandidate = fileNameCandidate.Replace(ch, '_');
                        }
                    }
                }
            }

            // Append the file name to current BaseDirectory.
            string filePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, fileNameCandidate);
            string availablePath = filePath;
            int suffix = 1;

            // If the target file already exists and overwriting is not allowed, construct a new file name by appending a number.
            // Repeat until we have unique and unused file name.
            while (!useOutputFileOption && File.Exists(availablePath))
            {
                availablePath = filePath + $".{suffix++}";
            }

            return availablePath;
        }

        /// <summary>
        /// Result of <see cref="DownloadUrlCore"/> method.
        /// </summary>
        private readonly struct DownloadResult
        {
            public DownloadResult(SimpleHttpResponse httpResponse, long totalDownloadedContentBytes)
            {
                HttpResponse = httpResponse;
                TotalDownloadedContentBytes = totalDownloadedContentBytes;
            }

            public SimpleHttpResponse HttpResponse { get; }
            public long TotalDownloadedContentBytes { get; }
        }

        /// <summary>
        /// Downloads the resource from the specified <param name="url"/> to <param name="targetFileName"/>.
        /// </summary>
        /// <param name="url">URL of the resource to download.</param>
        /// <param name="targetFileName">Target file name.</param>
        /// <param name="targetStream">Target stream opened for writing.</param>
        /// <param name="options">Parsed command line options.</param>
        /// <param name="downloadProgressReporter">Optional implementation of the IProgress<DownloadProgressData /> interface.</param>
        /// <returns>A task that represents the asynchronous download operation.</returns>
        private async Task<DownloadResult> DownloadUrlCore(
            Uri url,
            string targetFileName,
            Stream targetStream,
            HttpDownloaderOptions options,
            IProgress<DownloadProgressInfo> downloadProgressReporter)
        {
            // Rebex TlsStream variable.
            TlsStream tlsStream = null;

            // System Socket variable.
            Socket socket = null;

            // StopWatch so we can timestamp the progress of the download of the resource.
            var elapsedTime = new Stopwatch();

            // Total bytes of downloaded content
            long totalDownloadedContentBytes = 0;

            // SimpleHttpResponse variable
            SimpleHttpResponse httpResponse = null;

            // Last download progress info
            DownloadProgressInfo lastProgressInfo = new DownloadProgressInfo(null, 0, targetFileName, TimeSpan.Zero);
            try
            {
                // Spider mode = don't download resource, only send a HEAD request.
                if (options.SpiderMode)
                {
                    WriteLine("Spider mode enabled. Check if remote file exists.");
                }

                WriteLine($"--{DateTime.Now:yyyy-MM-dd HH:mm:ss}-- {url}");

                // Create a network socket and connect to the target host
                socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
                Write($"Connecting to {url.Host}:{url.Port}... ");
                socket.Connect(url.Host, url.Port);
                WriteLine("OK");
                WriteLine($"Connected to {EndPointToString(socket.RemoteEndPoint)}");


                // Create an instance of Rebex TlsStream to secure the connection with TLS.
                // TlsStream expects a connected socket as constructor argument and becomes the owner of the socket.
                // TlsStream has synchronous Dispose method, but prefer asynchronous DisposeAsync and CloseAsync methods
                // and do not use synchronous using block.
                tlsStream = new TlsStream(socket);
                // Prefer DisposeAsync method on modern platform and use an asynchronous using block.
                await using (tlsStream.ConfigureAwait(false))
                {
                    // Configure TlsStream. For more information about the TLS configuration,
                    // see https://www.rebex.net/tls/features/simple-tls-api.aspx#settings

                    // Only allow TLS 1.3 and TLS 1.2.
                    tlsStream.Parameters.Version = TlsVersion.TLS12 | TlsVersion.TLS13;

                    // Set timeout (IO_TIMEOUT_IN_MS represents 30 seconds) for Read and Write operations.
                    tlsStream.ReadTimeout = IO_TIMEOUT_IN_MS;
                    tlsStream.WriteTimeout = IO_TIMEOUT_IN_MS;

                    // If server certificate validation is disabled, accept all server certificates.
                    // IMPORTANT: DO NOT disable validation of server certificates in production!
                    if (options.NoCheckCertificate)
                    {
                        // Disabling server certificate validation makes the client vulnerable to trivial man-in-the-middle attacks.
                        WriteLine("WARNING: Validation of the server certificate is disabled.");

                        // Register a custom event handler that accepts server certificates without any validation.
                        tlsStream.ValidatingCertificate += (sender, args) => args.Accept();
                    }

                    // Our instance is the 'client' side of the connection, so call the AuthenticateAsClientAsync method and
                    // add the host name as the argument to the method.
                    // Host name is needed for TLS SNI (Server Name Indication) extension and for server certificate validation.
                    Write("Negotiation TLS session... ");
                    await tlsStream.AuthenticateAsClientAsync(url.Host).ConfigureAwait(false);
                    // Connection is now secured with TLS
                    WriteLine("OK");
                    WriteLine($"Cipher: {tlsStream.Cipher}");

                    // Construct an HTTP command. Use HEAD command in spider mode and GET command in standard mode.
                    byte[] getHttpCommand = options.SpiderMode
                        ? CreateHttpHeadCommand(url)
                        : CreateHttpGetCommand(url, options);

                    // Write the HTTP command to the TLS stream.
                    await tlsStream.WriteAsync(getHttpCommand, 0, getHttpCommand.Length).ConfigureAwait(false);
                    Write("HTTP request sent, awaiting response... ");

                    // Prepare buffer for reading.
                    var readBuffer = new byte[RECEIVE_BUFFER_SIZE_IN_BYTES];

                    // How many bytes we have read in the current iteration. Used in while loop below.
                    int readBytes = 0;

                    // Prepare our simple HTTP parser.
                    var httpParser = new SimpleHttpParser();

                    // We don't know how many bytes the server will send in the HTTP response - yet.
                    // For now, set the value of the variable toReadTotalBytes and the variable toReadBytesNow to the receive buffer size.

                    // Total number of remaining bytes that should be read.
                    long toReadTotalBytes = RECEIVE_BUFFER_SIZE_IN_BYTES;

                    // Maximum number of bytes to read in the next iteration.
                    int toReadBytesNow = RECEIVE_BUFFER_SIZE_IN_BYTES;

                    // Read data from the server until:
                    //  a) All data has been received.
                    //  b) The server has gracefully closed the connection.
                    //  c) An exception has occurred.
                    //  d) Code in the body of the while control statement have ended the loop.
                    while ((toReadTotalBytes > 0) && (readBytes = await tlsStream.ReadAsync(readBuffer, 0, toReadBytesNow).ConfigureAwait(false)) > 0)
                    {
                        // Assume that all received data is content data that should be written to the targetStream 
                        // and change this preliminary assumption after the HTTP response has been parsed.
                        int writeDataToFileOffset = 0;

                        // Absence of an instance of SimpleHttpResponse means that we are still parsing HTTP headers.
                        if (httpResponse == null)
                        {
                            // Call our HTTP parser.
                            // If the parser encounters end of HTTP headers, it will return tuple with SimpleHttpResponse and the number of the processed bytes.
                            httpResponse = httpParser.ProcessBytes(new ArraySegment<byte>(readBuffer, 0, readBytes), out int processedBytes);

                            // If the HTTP parser returned HTTP response...
                            if (httpResponse != null)
                            {
                                // Start StopWatch, which measures the time needed to download the content.
                                elapsedTime.Start();

                                // If the -S option (print server response) is present, write raw HTTP headers to the console.
                                if (options.PrintServerResponse)
                                {
                                    WriteLine(httpResponse.RawHttpResponseHeaders);
                                }
                                else
                                {
                                    // Write HTTP status code and message.
                                    WriteLine($"{httpResponse.StatusCode} {httpResponse.StatusMessage}");
                                }

                                // If the request is redirected, do nothing and return the response.
                                // See the implementation of DownloadUrl method that handles redirects and restarts
                                // the download operation on the new URL.
                                if (httpResponse.IsRedirectResponse)
                                {
                                    return new DownloadResult(httpResponse, 0);
                                }

                                // If the server returns 2xx response...
                                if (httpResponse.IsOkResponse)
                                {
                                    // Extract and print the value of 'Content-length' header (if present) and the value of 'Content-type' header.
                                    WriteLine($"Length: {httpResponse.ContentLength?.ToString() ?? UNKNOWN_CONTENT_LENGTH_STRING} [{httpResponse.ContentType}]");

                                    // Print the targetFileName if the spider mode is not enabled.
                                    if (!options.SpiderMode)
                                    {
                                        WriteLine($"Saving to: '{targetFileName}'");
                                    }

                                    // If we have the 'Content-length' header, but its value is 0, then we are done. Nothing to download, end the loop.
                                    if (httpResponse.ContentLength == 0)
                                    {
                                        break;
                                    }
                                }

                                // If we are in spider mode...
                                if (options.SpiderMode)
                                {
                                    // If server returns '404' (Not Found) status, print the error and break the loop.
                                    if (httpResponse.IsNotFoundError)
                                    {
                                        WriteLine("Remote file does not exist - broken link.");
                                        break;

                                    }

                                    // If server returns 2xx status, print a message and break the loop.
                                    if (httpResponse.IsOkResponse)
                                    {
                                        WriteLine("Remote file exists.");
                                        break;
                                    }
                                }

                                // If the HTTP response contains error status, print info about the error and throw HttpException.
                                if (httpResponse.IsErrorResponse)
                                {
                                    WriteLine($"--{DateTime.Now:yyyy-MM-dd HH:mm:ss}-- ERROR {httpResponse.StatusCode} {httpResponse.StatusMessage}");
                                    throw new HttpException(httpResponse.StatusCode, httpResponse.StatusMessage);
                                }

                                // If the server sent 'Content-length' header, keep the value in toReadTotalBytes variable.
                                if (httpResponse.ContentLength != null)
                                {
                                    toReadTotalBytes = httpResponse.ContentLength.Value;
                                    //Report progress
                                    lastProgressInfo = new DownloadProgressInfo(
                                        totalLength: toReadTotalBytes,
                                        downloadedBytes: 0,
                                        fileName: targetFileName,
                                        elapsedTime: TimeSpan.Zero);

                                    downloadProgressReporter?.Report(lastProgressInfo);
                                }
                            }

                            // If we are still parsing HTTP headers, go to the next iteration.
                            if (httpResponse == null)
                            {
                                continue;
                            }

                            // If our HTTP parser consumed all bytes, go to the next iteration -
                            // we don't have content data yet and we must read the next chunk of the server response.
                            if (readBytes == processedBytes)
                            {
                                continue;
                            }

                            // We have httpResponse, but our HTTP parser did not process all received data.
                            // This means that remaining data is part of the content and should be saved to the target file.
                            // Subtract the number of already processed bytes from the readBytes variable.
                            readBytes -= processedBytes;

                            // Start of the content bytes in the readBuffer.
                            writeDataToFileOffset = processedBytes;
                        }

                        // Write received data to targetStream.
                        await targetStream.WriteAsync(readBuffer, writeDataToFileOffset, readBytes).ConfigureAwait(false);

                        // Update totalDownloadedContentBytes variable
                        totalDownloadedContentBytes += readBytes;

                        // Report current progress of the download.
                        if (!_quietMode)
                        {
                            var elapsed = elapsedTime.Elapsed;
                            var totalElapsedMillisecondsAfterLastProgressReport = (elapsed - lastProgressInfo.ElapsedTime).TotalMilliseconds;
                            if (totalElapsedMillisecondsAfterLastProgressReport > REPORT_DOWNLOAD_PROGRESS_INTERVAL_IN_MILLISECONDS ||
                                totalDownloadedContentBytes == httpResponse.ContentLength)
                            {
                                lastProgressInfo = lastProgressInfo.With(elapsed, totalDownloadedContentBytes);
                                downloadProgressReporter?.Report(lastProgressInfo);
                            }
                        }

                        // Subtract bytes received in this iteration from the remaining bytes
                        // if the server sent the 'Content-length' header. Otherwise, just reset the value of toReadTotalBytes variable.
                        toReadTotalBytes = httpResponse.ContentLength != null
                            ? toReadTotalBytes - readBytes
                            : RECEIVE_BUFFER_SIZE_IN_BYTES;

                        // Determine the maximum number of bytes to read in the next iteration.
                        toReadBytesNow = (int)Math.Min(RECEIVE_BUFFER_SIZE_IN_BYTES, toReadTotalBytes);
                    }

                    // Return a Tuple with an instance of SimpleHttpResponse and the total number of downloaded content bytes.
                    return new DownloadResult(httpResponse, totalDownloadedContentBytes);
                }
                // tlsStream is now disposed, closing the underlying system Socket as well.
            }
            catch (Exception)
            {
                if (tlsStream == null)
                {
                    // If the exception was thrown before the TlsStream has the chance to adopt the system Socket, ensure that the socket is closed.
                    socket?.Dispose();
                }

                // Propagate the exception.
                throw;
            }
            finally
            {
                // Stop the timer.
                elapsedTime.Stop();
            }
        }

        /// <summary>
        /// Helper method that creates an HTTP HEAD command.
        /// </summary>
        /// <param name="url">URL.</param>
        /// <returns>HTTP HEAD command for the given <param name="url"/>.</returns>
        private static byte[] CreateHttpHeadCommand(Uri url)
        {
            string headCommand = string.Format(HTTP_HEAD_COMMAND_TEMPLATE, url.PathAndQuery);
            byte[] commandBytes = Encoding.ASCII.GetBytes(headCommand);
            return commandBytes;
        }

        /// <summary>
        /// Helper method that creates an HTTP GET command.
        /// </summary>
        /// <param name="url">URL.</param>
        /// <param name="options">Parsed command lne options.</param>
        /// <returns>HTTP GET command for the given <param name="url"/>.</returns>
        private static byte[] CreateHttpGetCommand(Uri url, HttpDownloaderOptions options)
        {
            string customUserAgent = string.IsNullOrWhiteSpace(options.UserAgent)
                ? string.Empty
                : $"User-Agent: {options.UserAgent}\r\n";

            string getCommand = string.Format(HTTP_GET_COMMAND_TEMPLATE, url.PathAndQuery, url.Host, customUserAgent);
            byte[] commandBytes = Encoding.ASCII.GetBytes(getCommand);
            return commandBytes;
        }

        /// <summary>
        /// Returns a string representation of the endpoint,
        /// translating IPv4-mapped IPv6 addresses to IPv4 format if needed.
        /// </summary>
        private static string EndPointToString(EndPoint ep)
        {
            var ip = ep as IPEndPoint;
            if (ip != null && ip.Address.IsIPv4MappedToIPv6)
                return $"{ip.Address.MapToIPv4()}:{ip.Port}";
            else
                return ip.ToString();
        }

        /// <summary>
        /// Helper method writes message to the console.
        /// </summary>
        private void Write(string message)
        {
            if (!_quietMode)
            {
                Console.Write(message);
            }
        }

        /// <summary>
        /// Helper method writes message and end-of-line to the console.
        /// </summary>
        private void WriteLine(string message)
        {
            if (!_quietMode)
            {
                Console.WriteLine(message);
            }
        }
    }
}
