// Copyright (C) 2005-2008, 2010-2013 panacorn <panacoran@users.sourceforge.jp>
// 
// This program is part of Protra.
//
// Protra is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, see <http://www.gnu.org/licenses/>.
// 
// $Id: PriceDataUpdator.cs 492 2013-09-14 15:24:10Z panacoran $

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Text;
using Protra.Lib.Data;
using SevenZip;

namespace Protra.Lib.Update
{
    /// <summary>
    /// 株価データソースを示す列挙型です。
    /// </summary>
    public enum PriceDataSource
    {
        /// <summary>
        /// 株価情報
        /// </summary>
        KabukaJoho,

        /// <summary>
        /// 無尽蔵
        /// </summary>
        Mujinzou,

        /// <summary>
        /// 株価データダウンロードサイト
        /// </summary>
        KdbCom,

        /// <summary>
        /// Yahoo!ファイナンス
        /// </summary>
        YahooFinance
    }

    /// <summary>
    /// HTTPによるファイルのダウンロードと圧縮されたファイルの展開を行う。
    /// </summary>
    public class DownloadUtil
    {
        private readonly HttpWebRequest _request;

        /// <summary>
        /// コンストラクタ
        /// </summary>
        public DownloadUtil(string url)
        {
            _request = (HttpWebRequest)WebRequest.Create(url);
            var u = GlobalEnv.UpdateConfig;
            if (u.UseProxy)
                _request.Proxy = new WebProxy(u.ProxyAddress, u.ProxyPort);
            _request.UserAgent = "Protra Updator";
            SevenZipBase.SetLibraryPath(Path.Combine(Global.DirApp, IntPtr.Size == 4 ? "7z.dll" : "7z64.dll"));
        }

        /// <summary>
        /// refererを設定する。
        /// </summary>
        public string Referer
        {
            set { _request.Referer = value; }
        }

        /// <summary>
        /// IfModifiedSinceを設定する。
        /// </summary>
        public DateTime IfModifiedSince
        {
            set { _request.IfModifiedSince = value; }
        }

        /// <summary>
        /// HTTPリクエストの応答を返す。
        /// </summary>
        /// <returns>レスポンスを読むためのStream</returns>
        public Stream GetResponse()
        {
            try
            {
                var response = _request.GetResponse().GetResponseStream();
                if (response != null)
                    return response;
            }
            catch (WebException e)
            {
                if (e.Status == WebExceptionStatus.ProtocolError &&
                    ((HttpWebResponse)e.Response).StatusCode == HttpStatusCode.NotModified)
                    return null;
                throw;
            }
            return null;
        }

        /// <summary>
        /// LHAで圧縮されたファイルをダウンロードして展開して返す。
        /// </summary>
        /// <returns>展開したファイルを読むためのStream</returns>
        public Stream DownloadAndExtract()
        {
            var response = GetResponse();
            if (response == null)
                return null;
            var url = _request.RequestUri.AbsolutePath;
            if (!url.EndsWith(".lzh", StringComparison.OrdinalIgnoreCase))
                return response;
// ReSharper disable AssignNullToNotNullAttribute
            var dst = Path.Combine(Global.DirTmp, Path.GetFileName(url));
// ReSharper restore AssignNullToNotNullAttribute
            var stream = new MemoryStream();
            using (response)
            using (var archive = File.Create(dst))
            {
                int n;
                var buf = new byte[4096];
                while ((n = response.Read(buf, 0, 4096)) > 0)
                    archive.Write(buf, 0, n);
                using (var extractor = new SevenZipExtractor(archive))
                {
                    if (extractor.ArchiveFileData.Count != 1)
                        throw new ApplicationException("データファイルの展開に失敗しました。");
                    extractor.ExtractFile(0, stream);
                }
            }
            File.Delete(dst);
            stream.Seek(0, SeekOrigin.Begin);
            return stream;
        }
    }

    /// <summary>
    /// 株価情報の更新を行う抽象クラスです。
    /// </summary>
    public abstract class PriceDataUpdator
    {
        /// <summary>
        /// 処理した一日分のレコード数を取得または設定する。
        /// </summary>
        protected int NumRecords { get; set; }

        /// <summary>
        /// 処理した日数を取得または設定する。
        /// </summary>
        protected int NumDays { get; set; }

        /// <summary>
        /// 処理したレコード数を取得または設定する。
        /// </summary>
        protected int DoneRecords { get; set; }

        /// <summary>
        /// 処理すべき一日分のレコード数を取得または設定する。
        /// </summary>
        protected int TotalRecords { get; set; }

        /// <summary>
        /// 処理すべき日数を取得または設定する。
        /// </summary>
        protected int TotalDays { get; set; }

        /// <summary>
        /// 処理中の日付を取得または設定する。
        /// </summary>
        protected DateTime Date { get; set; }

        /// <summary>
        /// 処理すべき最後の日付を取得または設定する。
        /// </summary>
        protected DateTime EndDate { get; set; }

        /// <summary>
        /// データのURLを取得する。
        /// </summary>
        /// <returns>URL</returns>
        protected abstract string DownloadUrl { get; }

        private long _startTicks; // 処理を開始したティック
        private long _startDownload; // ダウンロードを開始したティック
        private long _ticksToDownload; // ダウンロードに要したティック

        /// <summary>
        ///  処理の開始に必要な設定をする。
        /// <param name="begin">処理を開始する日付</param>
        /// <param name="end">処理を終了する日付</param>
        /// </summary>
        protected void Start(DateTime begin, DateTime end)
        {
            // 市場が開いている日数を数える。
            for (var d = begin; d <= end; d = d.AddDays(1))
                if (Calendar.IsMarketOpen(d))
                {
                    if (Date == DateTime.MinValue)
                        Date = d; // 最初の市場が開いている日に設定する。
                    TotalDays++;
                }
            if (Date == DateTime.MinValue) // begin > endの場合。
                Date = begin;
            // 最も最近の市場が開いている日に設定する。
            for (EndDate = end; EndDate >= begin; EndDate = EndDate.AddDays(-1))
                if (Calendar.IsMarketOpen(EndDate))
                    break;
            _startTicks = DateTime.Now.Ticks;
        }

        /// <summary>
        /// 日付を次の市場が開いている日に進める。
        /// </summary>
        protected void NextDate()
        {
            do
            {
                Date = Date.AddDays(1);
            } while (!Calendar.IsMarketOpen(Date) && Date < EndDate);
        }

        /// <summary>
        /// 指定されたデータソースに対応する具象クラスのインスタンスを返す。
        /// </summary>
        public static PriceDataUpdator Create()
        {
            var u = GlobalEnv.UpdateConfig;
            PriceDataUpdator r;
            switch (u.PriceDataSource)
            {
                case PriceDataSource.KabukaJoho:
                    r = new KabukaJohoUpdator();
                    break;
                case PriceDataSource.Mujinzou:
                    r = new MujinzouUpdator();
                    break;
                case PriceDataSource.KdbCom:
                    r = new KdbComUpdator();
                    break;
                case PriceDataSource.YahooFinance:
                    r = new YahooFinanceUpdator();
                    break;
                default:
                    return null;
            }
            return r;
        }

        /// <summary>
        /// データソースの名前の一覧を取得する。
        /// </summary>
        public static string[] DataSourceNames
        {
            get
            {
                return new[]
                    {
                        "株価情報",
                        "無尽蔵",
                        "株価データダウンロードサイト",
                        "Yahoo!ファイナンス"
                    };
            }
        }

        /// <summary>
        /// データソースの説明を取得する。
        /// </summary>
        /// <param name="dataSource">データソースを指定する</param>
        /// <returns>データソースの説明</returns>
        public static string GetDescription(PriceDataSource dataSource)
        {
            switch (dataSource)
            {
                case PriceDataSource.KabukaJoho:
                    return "2000年からのデータを取得できますが、2005年までは大証のデータがありません。";
                case PriceDataSource.Mujinzou:
                    return "1996年からのデータを取得できます。";
                case PriceDataSource.KdbCom:
                    return "約二年前からのデータを取得できます。";
                case PriceDataSource.YahooFinance:
                    return "1991年からのデータを取得できますが、非常に時間がかかります。";
            }
            return "";
        }

        /// <summary>
        /// データが存在する最初の日付を取得する。
        /// </summary>
        public abstract DateTime DataSince { get; }

        /// <summary>
        /// 株価データとindex.txtを更新する。
        /// </summary>
        /// <param name="worker">BackgroundWorker</param>
        /// <param name="e">DoWorkイベントの引数</param>
        public void Update(BackgroundWorker worker, DoWorkEventArgs e)
        {
            worker.ReportProgress(0, "銘柄データ");
            GlobalEnv.BrandData.Update(); // index.txtを更新する。
            if (worker.CancellationPending)
            {
                e.Cancel = true;
                return;
            }
            UpdatePrice(worker, e);
            worker.ReportProgress(100, "");
        }

        /// <summary>
        /// 株価データを更新する。
        /// </summary>
        /// <param name="worker">BackgroundWorker</param>
        /// <param name="e">DoWorkイベントの引数</param>
        protected virtual void UpdatePrice(BackgroundWorker worker, DoWorkEventArgs e)
        {
            var begin = (DateTime)e.Argument;
            if (begin < DataSince)
                begin = DataSince;
            var today = DateTime.Now;
            // 今日のデータがまだ置かれていない。
            if (!IsDataAvailable(today))
                today = today.AddDays(-1);
            for (Start(begin, today); Date <= EndDate; NextDate())
            {
                NumRecords = 0; // 進捗の計算に使うのでここでリセットする。
                UpdateProgress(worker);
                var dl = new DownloadUtil(DownloadUrl);
                _startDownload = DateTime.Now.Ticks;
                var prices = new List<Price>();
                using (var stream = dl.DownloadAndExtract())
                {
                    if (worker.CancellationPending)
                    {
                        e.Cancel = true;
                        PriceData.CloseAll();
                        return;
                    }
                    if (stream == null)
                    {
                        TotalDays--;
                        continue;
                    }
                    _ticksToDownload += DateTime.Now.Ticks - _startDownload;
                    using (var reader = new StreamReader(stream, Encoding.GetEncoding("shift_jis")))
                    {
                        string line;
                        Price prev = null;
                        while ((line = reader.ReadLine()) != null)
                        {
                            var tmp = ParseLine(line);
                            if (tmp == null)
                                continue;
                            if (prev == null)
                            {
                                prev = tmp;
                                continue;
                            }
                            if (prev.Code == tmp.Code)
                            {
                                // 重複上場の場合は出来高の大きなほうを優先する。
                                if (tmp.Volume > prev.Volume)
                                    prev = tmp;
                                continue;
                            }
                            prices.Add(prev);
                            prev = tmp;
                        }
                        if (prev != null)
                            prices.Add(prev);
                    }
                }
                TotalRecords = prices.Count;
                for (; NumRecords < TotalRecords; NumRecords++, DoneRecords++, UpdateProgress(worker))
                {
                    if (worker.CancellationPending)
                    {
                        e.Cancel = true;
                        PriceData.CloseAll();
                        return;
                    }
                    PriceData.Add(prices[NumRecords], Date == EndDate);
                }
                PriceData.MaxDate = Date;
                NumDays++;
            }
            PriceData.CloseAll();
        }

        /// <summary>
        /// 新しいデータが置かれる時刻に達しているか。
        /// </summary>
        /// <param name="time">時刻</param>
        /// <returns></returns>
        protected abstract bool IsDataAvailable(DateTime time);


        /// <summary>
        /// 文字列を解析して価格データを返す。
        /// </summary>
        /// <param name="line">文字列</param>
        /// <returns>価格データ</returns>
        protected abstract Price ParseLine(string line);

        private int _prevProgress = -1;
        private int _prevNumDyas;
        private long _prevTicks;

        /// <summary>
        /// 残りの所要時間と進捗を計算して表示する。
        /// </summary>
        /// <param name="worker">BackgroundWorker</param>
        protected void UpdateProgress(BackgroundWorker worker)
        {
            var progress = DoneRecords == 0
                               ? 0
                               : (int)
                                 ((float)DoneRecords * 100 /
                                  (DoneRecords + TotalRecords - NumRecords + (TotalDays - NumDays - 1) * TotalRecords));
            if (progress > 100)
                progress = 100;
            if (progress == _prevProgress && NumDays == _prevNumDyas &&
                DateTime.Now.Ticks - _prevTicks < (long)20 * 10000000) // 20秒以上経ったら残り時間を更新する。
                return;
            _prevNumDyas = NumDays;
            _prevProgress = progress;
            _prevTicks = DateTime.Now.Ticks;
            var message = String.Format("{0:d} ({1}/{2}) ", Date, NumDays + 1, TotalDays) + LeftTime();
            worker.ReportProgress(progress, message);
        }

        /// <summary>
        /// 残り時間を文字列表現で返す。
        /// </summary>
        /// <returns>文字列で表した残り時間</returns>
        private string LeftTime()
        {
            var leftTime = CalcLeftTime();
            if (leftTime == 0) // 処理が始まる前も0になる。
                return "";
            var s = "残り";
            if (leftTime <= 50)
                s += " " + leftTime + " 秒";
            else
            {
                if (leftTime >= 3600)
                {
                    s += " " + (leftTime / 3600) + " 時間";
                    leftTime %= 3600;
                }
                if (leftTime > 0)
                    s += " " + ((leftTime + 59) / 60) + " 分"; // 切り上げ
            }
            return s;
        }

        /// <summary>
        /// 残り時間を計算する。
        /// </summary>
        /// <returns>秒数であらわした残り時間</returns>
        private int CalcLeftTime()
        {
            var ticksToRecords = (DateTime.Now.Ticks - _startTicks) - _ticksToDownload;
            var leftTicks = _ticksToDownload / (NumDays + 1) * (TotalDays - NumDays - 1);
            if (DoneRecords > 0)
            {
                var leftRecords = TotalRecords - NumRecords + (TotalDays - NumDays - 1) * TotalRecords;
                leftTicks += ticksToRecords / DoneRecords * leftRecords;
            }
            return (int)(leftTicks / 10000000);
        }
    }
}