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

using System;
using System.Data;
using System.IO;
using System.Net;
using System.Text;
using System.Xml;
using TERASOLUNA.Fw.Common;
using TERASOLUNA.Fw.Common.Logging;

namespace TERASOLUNA.Fw.Client.Communication
{
    /// <summary>
    /// バイナリファイル受信機能を提供するクラスです。
    /// </summary>
    public class BinaryFileReceiver : ReceiverBase
    {
        /// <summary>
        /// <see cref="ILog"/> 実装クラスのインスタンスです。
        /// </summary>
        /// <remarks>
        /// ログ出力に利用します。
        /// </remarks>
        private static ILog _log = LogFactory.GetLogger(typeof(BinaryFileReceiver));

        /// <summary>
        /// HTTPヘッダにContent-Dispositionヘッダを設定する際、キーとして利用する文字列です。
        /// </summary>
        /// <remarks>
        /// <para>定数の値は "Content-Disposition" です。</para>
        /// </remarks>
        protected static readonly string CONTENT_DISPOSITION = "Content-Disposition";

        /// <summary>
        /// Content-Dispositionの値からファイル名を取得する際にキーとして利用する文字列です。
        /// </summary>
        /// <remarks><para>この定数の値は "filename" です。</para></remarks>
        protected static readonly string CONTENT_DISPOSITION_FILENAME = "filename";

        /// <summary>
        /// サーバから受信したダウンロードファイル名をイベントハンドラへ送信する際にキーとして利用する文字列です。
        /// </summary>
        /// <remarks><para>この定数の値は "ContentFileName" です。</para></remarks>
        protected static readonly string CONTENT_FILENAME = "ContentFileName";

        /// <summary>
        /// ダウンロードファイルの出力パスを取得する際にキーとして利用する文字列です。
        /// </summary>
        /// <remarks><para>この定数の値は "DownloadFilePath" です。</para></remarks>
        protected static readonly string DOWNLOAD_FILEPATH = "DownloadFilePath";

        /// <summary>
        /// 進行状況イベントハンドラへダウンロード通知を行う際にキーとして利用する文字列です。
        /// </summary>
        /// <remarks><para>この定数の値は "DownloadReady" です。</para></remarks>
        protected static readonly string DOWNLOAD_READY = "DownloadReady";

        /// <summary>
        /// ファイル名のエンコード／デコードのタイプを表す文字列です。
        /// </summary>
        /// <remarks>
        /// <para>この定数の値は "B" です。"B" は、B エンコードを表します。</para>
        /// <para>
        /// B エンコードとは、7bit US-ASCII 以外のテキストを使用するための 7bit エンコーディングメカニズム
        /// の一つです。RFC 2047 で定義されています。エンコーディングアルゴリズムは Base64 と同じです。
        /// </para>
        /// </remarks>
        protected static readonly string ENCODE_TYPE = "B";

        /// <summary>
        /// <see cref="BinaryFileReceiver"/> クラスの新しいインスタンスを初期化します。
        /// </summary>
        /// <remarks>
        /// デフォルトコンストラクタです。
        /// </remarks>
        public BinaryFileReceiver()
        {
        }

        /// <summary>
        /// <paramref name="response"/> で渡された <see cref="HttpWebResponse"/>を用いて ファイルダウンロード処理を
        /// 行います。
        /// </summary>
        /// <remarks>
        /// <para>
        /// <paramref name="response"/> で渡された <see cref="HttpWebResponse"/> から HTTP レスポンスボディを取得し、
        /// バイナリストリームとしてローカルファイルに書き込みます。保存するためのファイル名を取得するために、
        /// <paramref name="reporter"/> を利用します。
        /// </para>
        /// <para>
        /// ダウンロード開始直前に、 <see cref="BinaryFileReceiver"/> は <see cref="IProgressChangeReporter.ReportProgressChanged"/>
        /// を呼び出します。このとき、<see cref="ExecuteProgressChangedEventArgs"/>の Items プロパティに、"DownloadReady" をキー
        /// として、 <c>true</c> フラグを設定します。呼び出される <paramref name="reporter"/> では、
        /// <see cref="IProgressChangeReporter.ReportProgressChanged"/> メソッドで "DownloadReady" キーの存在を調べ、
        /// 存在していた場合には、<see cref="ExecuteProgressChangedEventArgs.Items"/> に "DownloadFilePath" をキーとして
        /// 保存先のファイルパスを表す文字列を追加します。 <see cref="BinaryFileReceiver"/> は "DownloadFilePath" に設定
        /// されているファイルパスを用いてダウンロードしたデータをファイルに保存します。キー "DownloadFilePath" が設定さ
        /// れていない場合、ダウンロード処理は行われません。 "DownloadReady" キーを <see cref="ExecuteProgressChangedEventArgs.Items"/>
        /// に設定して <see cref="IProgressChangeReporter.ReportProgressChanged"/> を呼び出す際、レスポンスの Content-Disposition
        /// ヘッダにファイル名が格納されていた場合、そのファイル名は "ContentFileName" をキーとして同時に
        /// <see cref="ExecuteProgressChangedEventArgs.Items"/> に設定されます。
        /// </para>
        /// <para>戻り値には、<see cref="CommunicationResult"/>の派生クラスである <see cref="DownloadResult"/> の
        /// インスタンスを返します。</para>
        /// <para>戻り値となる <see cref="DownloadResult"/> の各プロパティは以下の通りです。</para>
        /// <para>
        /// <list type="table">
        /// <listheader>
        ///     <term>DownloadResultのプロパティ名</term>
        ///     <description>設定される値</description>
        /// </listheader>
        /// <item>
        ///     <term>DownloadFilePath</term>
        ///     <description>ダウンロード処理が行われた場合、保存先のファイル名が格納されます。ダウンロード処理が
        ///     行われなかった場合、 <c>null</c> 参照です。</description>
        /// </item>
        /// <item>
        ///     <term>Headers</term>
        ///     <description>HTTP レスポンスヘッダのキー/値が格納されます。</description>
        /// </item>
        /// <item>
        ///     <term>ResultData</term>
        ///     <description>受信した XML データを格納したデータセット。</description>
        /// </item>
        /// </list>
        /// </para>
        /// <para>
        /// サーバから送られる Cookie などのヘッダは <see cref="CommunicationResult.ResponseHeaders"/>　に格納されます。
        /// 利用者は必要に応じてそれらの情報を利用することができます。
        /// </para>
        /// <para>
        /// <paramref name="reporter"/> が <c>null</c> 参照ではない場合、
        /// <see cref="ReceiverBase.BufferSize"/> プロパティに設定されたバッファサイズ分のデータ（デフォルトは8 KB ）をサーバに送信する毎に、
        /// <see cref="IProgressChangeReporter.ReportProgressChanged"/> イベントを発生させます。
        /// </para>
        /// </remarks>
        /// <param name="response">受信処理を行う HTTP レスポンスオブジェクト。</param>
        /// <param name="reporter">進行状況通知を行う<see cref="IProgressChangeReporter"/>インスタンス。</param>
        /// <returns>受信結果を保持した <see cref="CommunicationResult"/>インスタンス。</returns>
        /// <exception cref="WebException">
        /// 通信エラーが発生しました。または、リクエストがキャンセルされました。
        /// </exception>
        /// <exception cref="CommunicationException">
        /// 以下のような場合に例外をスローします。
        /// <list type="bullet">
        /// <item>
        /// <description>
        /// サーバでエラーが発生しており、かつ、受信データが XML 形式ではありません。
        /// </description>
        /// </item>
        /// <item>
        /// <description>
        /// サーバでエラーが発生しており、かつ、受信した XML データにルートノードがありません。
        /// </description>
        /// </item>
        /// <item>
        /// <description>
        /// Content-Disposition に格納されたファイル名のデコードに失敗しました。
        /// </description>
        /// </item>
        /// <item>
        /// <description>
        /// 通信処理中にエラーが発生しました。
        /// </description>
        /// </item>
        /// </list>
        /// </exception>
        /// <exception cref="ServerException">
        /// サーバで業務エラーが発生しました。サーバでの業務エラーは HTTP レスポンスヘッダに "exception" 
        /// ヘッダがあるかどうかで判断します。エラー情報のハンドリングについては <see cref="ReceiverBase.HandleErrorResult"/> 
        /// を参照します。
        /// </exception>
        protected override CommunicationResult ReceiveResponse(HttpWebResponse response,
                                                                IProgressChangeReporter reporter)
        {
            // 受信情報のログ出力
            if (_log.IsTraceEnabled)
            {
                // HTTPヘッダ
                StringBuilder responseHeader = new StringBuilder();
                responseHeader.AppendLine(Properties.Resources.T_RESPONSE_RECEIVE_HEADER);
                foreach (string key in response.Headers.AllKeys)
                {
                    responseHeader.AppendLine(string.Format(
                    Properties.Resources.T_DICTIONARY_KEY_VALUE,
                    key, response.Headers[key]));
                }
                _log.Trace(responseHeader.ToString().Trim());
            }

            if (reporter == null)
            {
                string message = string.Format(Properties.Resources.E_NULL_ARGUMENT, "reporter");
                ArgumentNullException exception = new ArgumentNullException("reporter");
                if (_log.IsErrorEnabled)
                {
                    _log.Error(message, exception);
                }
                throw exception;
            }

            string errorType = response.Headers[ERROR_TYPE];
            if (errorType != null)
            {
                // サーバのエラー情報受信
                XmlDocument receiveXml = GetXmlFromResponseBody(response, reporter);

                // 受信情報のログ出力
                if (_log.IsTraceEnabled)
                {
                    // HTTPボディ
                    StringBuilder responseBody = new StringBuilder();
                    responseBody.AppendLine(Properties.Resources.T_RESPONSE_RECEIVE_BODY);
                    using (Stream st = new MemoryStream())
                    {
                        receiveXml.Save(st);
                        st.Position = 0;
                        StreamReader reader = new StreamReader(st);
                        responseBody.Append(reader.ReadToEnd());
                    }
                    _log.Trace(responseBody.ToString());
                }

                ServerException exception = HandleErrorResult(errorType, receiveXml);
                if (_log.IsErrorEnabled)
                {
                    _log.Error(exception.Message, exception);
                }
                throw exception;
            }

            // ヘッダに格納されたファイル名取得
            string contentFileName = GetContentFileName(response);

            // 保存するファイルパスを取得
            string filePath = GetDownloadFilePath(contentFileName, reporter);

            DownloadResult result = null;

            // 保存するファイル名が取得できた場合にはダウンロード・保存処理を行う。
            if (!string.IsNullOrEmpty(filePath))
            {
                result = ReceiveAndSaveBinaryFile(response, filePath, reporter);

                // 受信情報のログ出力
                if (_log.IsTraceEnabled)
                {
                    // 受信ファイルの保存パス
                    _log.Trace(string.Format(Properties.Resources.T_DOWNLOAD_FILE_PATH, filePath));
                }
            }
            else
            {
                result = CreateDownloadResult(response, null);

                // 受信情報のログ出力
                if (_log.IsTraceEnabled)
                {
                    // ファイルを受信できなかったことをログ出力する
                    _log.Trace(string.Format(Properties.Resources.T_DOWNLOAD_FAILED, filePath));
                }
            }

            return result;
        }

        /// <summary>
        /// HTTP レスポンスヘッダの Content-Disposition フィールドに格納されたファイル名を取得します。
        /// </summary>
        /// <remarks>
        /// HTTP レスポンスヘッダに Content-Disposition フィールドが存在する場合、
        /// filename 属性の値をファイル名として切り出し、 <see cref="DecodeFileName"/> メソッドを用いて
        /// デコードして返却します。
        /// </remarks>
        /// <param name="response">HTTP レスポンスオブジェクト。</param>
        /// <returns>サーバから受信したダウンロードファイル名を表す文字列。</returns>
        /// <exception cref="CommunicationException">
        /// Content-Disposition に含まれるエンコード名は、サポートされていません。
        /// </exception>
        protected virtual string GetContentFileName(WebResponse response)
        {
            // ヘッダにContent-Disopositionが存在しない場合は何もしない
            string contentDisposition = response.Headers.Get(CONTENT_DISPOSITION);
            if (contentDisposition == null)
            {
                return null;
            }

            // Content-Dispositionヘッダを";"で分解できない場合は何もしない
            string[] strArray = contentDisposition.Split(';');
            if (strArray.Length <= 1)
            {
                return null;
            }

            // filename以降の文字列を取得する
            contentDisposition = strArray[1];

            string contentFileName = null;

            // 最初の'='で文字列を分割する
            int substrPos = contentDisposition.IndexOf('=');
            if (substrPos >= 0)
            {
                string keyStr = contentDisposition.Substring(0, substrPos).Trim();
                string valueStr = contentDisposition.Substring(substrPos + 1).Trim();
                // filename以降の文字列を取得する
                if (CONTENT_DISPOSITION_FILENAME.Equals(keyStr) && !string.IsNullOrEmpty(valueStr))
                {
                    contentFileName = DecodeFileName(valueStr);
                }
            }

            return contentFileName;
        }

        /// <summary>
        /// Content-Disposition ヘッダから取得したファイル名をデコードするためのヘルパーメソッドです。
        /// </summary>
        /// <param name="encodeStr">Content-Disposition ヘッダの filename 属性に格納されている値。</param>
        /// <returns>デコードしたファイル名。</returns>
        /// <remarks>
        /// <para>Content-Disposition ヘッダに含まれるエンコード方式が "B" の場合、デコードしたファイル名を返却します。</para>
        /// <para>"B" 以外のエンコード方式が指定された場合は、例外をスローします。</para>
        /// </remarks>
        /// <exception cref="CommunicationException">
        /// Content-Disposition ヘッダに含まれるエンコード名は、サポートされていません。
        /// </exception>
        protected virtual string DecodeFileName(string encodeStr)
        {
            // 要素が正しい形式ではない、またはBase64形式ではない場合何もしない
            string[] strArray = encodeStr.Split('?');
            if (strArray.Length < 5 || !ENCODE_TYPE.Equals(strArray[2]))
            {
                return null;
            }

            string fileName = null;
            byte[] byteArray = Convert.FromBase64String(strArray[3]);
            try
            {
                fileName = Encoding.GetEncoding(strArray[1]).GetString(byteArray);
            }
            catch (ArgumentException e)
            {
                CommunicationException exception = new CommunicationException(
                    string.Format(Properties.Resources.E_COMMUNICATION_INVALID_ENCODING, strArray[1]), e);
                if (_log.IsErrorEnabled)
                {
                    _log.Error(exception.Message, exception);
                }
                throw exception;
            }

            return fileName;
        }

        /// <summary>
        /// <paramref name="reporter"/> の <see cref="IProgressChangeReporter.ReportProgressChanged"/> を用いて
        /// 保存先ファイル名を取得するためのヘルパーメソッドです。
        /// </summary>
        /// <remarks>
        /// <para> キー "DownloadReady" を用いて、<paramref name="reporter"/> にダウンロード準備完了を伝え、
        /// ファイル名を取得します。また、サーバが指定した <paramref name="contentFileName"/> を キー"ContentFileName"
        /// を用いて指定します。それらの情報は <see cref="IProgressChangeReporter.ReportProgressChanged"/> へ渡す
        /// <see cref="ExecuteProgressChangedEventArgs"/> の <see cref="ExecuteProgressChangedEventArgs.Items"/> プロパティに
        /// 指定します。
        /// </para>
        /// <para>
        /// 呼び出される <paramref name="reporter"/> では、<see cref="IProgressChangeReporter.ReportProgressChanged"/> 
        /// メソッドで <see cref="ExecuteProgressChangedEventArgs.Items"/> から"DownloadReady" キーの存在を調べ、存在して
        /// いた場合には、<see cref="ExecuteProgressChangedEventArgs.Items"/> に "DownloadFilePath" をキーとして
        /// 受信したデータの保存先のファイルパスを表す文字列を追加します。 
        /// </para>
        /// <para>
        /// <paramref name="reporter"/> が <see cref="ExecuteProgressChangedEventArgs.Items"/> にキー "DownloadFilePath"
        /// で設定した文字列を、ファイル名として返却します。
        /// </para>
        /// </remarks>
        /// <param name="contentFileName">サーバがファイル名として指定した文字列。</param>
        /// <param name="reporter">進行状況通知を行う<see cref="IProgressChangeReporter"/>インスタンス。</param>
        /// <returns><paramref name="reporter"/> によって指定されたダウンロードデータの保存先ファイル名。</returns>
        protected virtual string GetDownloadFilePath(string contentFileName, IProgressChangeReporter reporter)
        {
            // ProgressChangedイベント発生(ダウンロード開始通知)
            ExecuteProgressChangedEventArgs eventArgs = new ExecuteProgressChangedEventArgs(50);
            eventArgs.Items.Add(DOWNLOAD_READY, true);
            eventArgs.Items.Add(CONTENT_FILENAME, contentFileName);

            // 発行
            reporter.ReportProgressChanged(eventArgs);

            // ダウンロードファイルパスを返す
            return eventArgs.Items[DOWNLOAD_FILEPATH] as string;
        }

        /// <summary>
        /// <paramref name="response"/> で渡された <see cref="HttpWebResponse"/>を用いて受信したデータを、
        /// <paramref name="saveFilePath"/> に保存します。
        /// </summary>
        /// <remarks>
        /// <para>
        /// <paramref name="response"/> で渡された <see cref="HttpWebResponse"/>を用いて受信したデータを、
        /// <paramref name="saveFilePath"/> に保存します。HTTP レスポンスヘッダに格納された情報は、戻り値の
        /// <see cref="DownloadResult"/> インスタンスの <see cref="CommunicationResult.ResponseHeaders"/> に
        /// 全て追加します。ファイルを正常に保存した場合、<see cref="DownloadResult.DownloadFilePath"/>
        /// に保存したファイルパスを表す文字列を格納します。
        /// </para>
        /// <para>
        /// <see cref="CommunicationResult.ResultData"/>には、空の <see cref="DataSet"/> インスタンスが格納されます。
        /// </para>
        /// <para>
        /// <paramref name="reporter"/> が <c>null</c> 参照ではない場合、
        /// <see cref="ReceiverBase.BufferSize"/> プロパティに設定されたバッファサイズ分のデータ（デフォルトは8 KB ）をサーバに送信する毎に、
        /// <see cref="IProgressChangeReporter.ReportProgressChanged"/> イベントを発生させます。
        /// </para>
        /// </remarks>
        /// <param name="response">受信処理を行った HTTP レスポンスオブジェクト。</param>
        /// <param name="saveFilePath">ダウンロードしたファイルを保存したファイルパス文字列。</param>
        /// <param name="reporter">進行状況通知を行う<see cref="IProgressChangeReporter"/>インスタンス。</param>
        /// <returns>通信結果を表す <see cref="DownloadResult"/> インスタンス。</returns>
        protected virtual DownloadResult ReceiveAndSaveBinaryFile(WebResponse response,
                                                             string saveFilePath,
                                                             IProgressChangeReporter reporter)
        {
            string tempFilePath = Path.GetTempFileName();
            try
            {
                // レスポンスからバイナリデータの読み込み準備
                using (Stream resStream = response.GetResponseStream())
                {
                    // 一時ファイルの書き込み準備
                    using (Stream downFileStream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write))
                    {
                        int localBufferSize = BufferSize;

                        int percentage = 0;
                        long receiveSize = 0L;
                        long totalSize = response.ContentLength;

                        byte[] buffer = new byte[localBufferSize];
                        int bufferSize = resStream.Read(buffer, 0, localBufferSize);
                        while (bufferSize > 0)
                        {
                            // ファイル書き込み
                            downFileStream.Write(buffer, 0, bufferSize);

                            // イベント通知
                            receiveSize += bufferSize;
                            // レポーターが存在していれば
                            if (reporter != null)
                            {
                                if (totalSize > 0)
                                {
                                    percentage = CalcReceivePercentage(receiveSize, totalSize);
                                }
                                reporter.ReportProgressChanged(new ExecuteProgressChangedEventArgs(percentage));

                            }

                            bufferSize = resStream.Read(buffer, 0, localBufferSize);
                        }
                    }
                }

                // 一時ファイルの移動
                File.Copy(tempFilePath, saveFilePath, true);
                    
            }
            catch (Exception e)
            {
                TerasolunaException exception = new TerasolunaException(string.Format(Properties.Resources.E_BINARY_DOWNLOAD_ERROR, saveFilePath), e);
                if (_log.IsErrorEnabled)
                {
                    _log.Error(exception.Message, exception);
                }
                throw exception;
            }
            finally
            {
                // 一時ファイル削除（ファイルがなくても、例外はスローされない）
                File.Delete(tempFilePath);
            }

            DownloadResult result = CreateDownloadResult(response, saveFilePath);
            return result;

        }

        /// <summary>
        /// HTTP レスポンスと受信したデータを格納した<paramref name="receiveData"/>基に、
        /// 通信結果を表す <see cref="CommunicationResult"/> インスタンスを作成します。
        /// </summary>
        /// <remarks>
        /// <para>
        /// 通信結果を表す <see cref="DownloadResult"/> を生成します。<see cref="CommunicationResult.ResultData"/>
        ///  プロパティに <paramref name="receiveData"/> を格納し、<see cref="CommunicationResult.ResponseHeaders"/>プロパティに
        /// HTTP レスポンスヘッダのキー/値を全て追加します。<see cref="DownloadResult.DownloadFilePath"/> プロパティに
        /// <paramref name="saveFilePath"/> で渡された文字列を設定します。
        /// </para>
        /// <para>
        /// <see cref="CommunicationResult.ResultData"/>には、空の <see cref="DataSet"/> インスタンスが格納されます。
        /// </para>
        /// <para>
        /// このメソッドは <c>null</c> 参照を返しません。
        /// </para>
        /// </remarks>
        /// <param name="response">受信処理を行った HTTP レスポンスオブジェクト。</param>
        /// <param name="saveFilePath">ダウンロードしたファイルを保存したファイルパス文字列。</param>
        /// <returns>通信結果を表す <see cref="DownloadResult"/> インスタンス。</returns>
        protected virtual DownloadResult CreateDownloadResult(WebResponse response, string saveFilePath)
        {
            DownloadResult result = new DownloadResult(saveFilePath);
            result.ResultData = new DataSet();
            foreach (string key in response.Headers.Keys)
            {
                result.ResponseHeaders.Add(key, response.Headers[key]);
            }
            return result;
        }

    }
}
