/*
 * $Id: RSSWebNavigator.java,v 1.10 2005/10/23 12:31:56 rampil Exp $
 * Copyright (c) 2005 LOGICAL-PARADOX.ORG
 */
package org.logical_paradox.rss.robot;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.rmi.RemoteException;
import java.util.Calendar;
import java.util.Hashtable;
import java.util.Vector;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.logical_paradox.common.net.IPAddressRangeSet;
import org.logical_paradox.common.net.IPAddressRangeSetFactory;
import org.logical_paradox.rss.IllegalObjectStateException;
import org.logical_paradox.rss.RSSConstant;
import org.logical_paradox.rss.RSSIllegalConfigException;
import org.logical_paradox.rss.RSSProperties;
import org.logical_paradox.rss.dsr.RSSDistributedServiceRegistry;
import org.logical_paradox.rss.dsync.SyncQueueException;
import org.logical_paradox.rss.http.HREFCollector;
import org.logical_paradox.rss.http.HTMLContentsFilter;
import org.logical_paradox.rss.http.NoIndexException;
import org.logical_paradox.rss.http.Site;
import org.logical_paradox.rss.http.URLPool;
import org.logical_paradox.rss.http.URLSuspender;
import org.logical_paradox.rss.http.WebContents;
import org.logical_paradox.rss.lcmgr.RSSLocalContentsMgr;
import org.logical_paradox.rss.lookup.Lookup;
import org.logical_paradox.rss.lookup.LookupFactory;
import org.logical_paradox.rss.robot.event.SNodeDSyncEventListener;
import org.logical_paradox.rss.router.RNodeClientFactory;
import org.logical_paradox.rss.router.RNodeDSyncMngr;
import org.logical_paradox.rss.router.RoutingNodeClient;
import org.logical_paradox.rss.router.SiteLock;
import org.logical_paradox.rss.router.algorithm.RoutingAlgorithm;
import org.logical_paradox.rss.util.RandomKeyGenerator;

/**
 * RSSWebNavigator
 * RSS Web自動巡回ロボットが次に探索するべき進路を決定するクラス
 * 必要に応じて，進路情報をマスターのサーバーとやりとりしつつ，次の進路を提示する
 * @author satoshi akabane@logical-paradox.org
 * @version $Revision: 1.10 $
 */
public class RSSWebNavigator {
	/** ロガー */
	private static final Log log = LogFactory.getLog(RSSWebNavigator.class);

	/** デフォルトの設定ファイル名 */
	public static String NAVIGATIOR_CONF_FILENAME = "conf/rssnavigator.conf";

	/** RSSWebNavigatorコンフィグレーション */
	private RSSNavigatorConfig cfg = null;

	/** 未処理URL */
	private URLPool urls = null;
	/** 現在トレースしたURL数 */
	private int nowFetchCnt = 0;
	/** トレース予定のURL数の最大 */
	private int maxFetchCnt = 0;
	/** 経路決定アルゴリズム */
	private RoutingAlgorithm algo = null;
	/** ロックを保有しているサイト */
	private Hashtable sites = null;

	/** 経路決定ノードのスタブ */
	private RNodeClientFactory rnode = null;
	/** 経路決定クライアント */
	private RoutingNodeClient rnClient = null;
	/** 受け取った探索ノードのID */
	private String robotId = null;
	/** 同時に保有できるロックの数 */
	private int limKeepLocks = 0;
	/** 探索抑止対象のURLを管理するオブジェクト */
	private URLSuspender suspender = null;
	/** 最後に探索ロック管理オブジェクトに対してGCを起動した時刻 */
	private long siteLockLastGCTime = 0;

	/** 強制停止フラグ */
	private boolean shutdownFlg = false;

	/** ナビゲーションモード(true:分散システム false:スタンドアローン) */
	private boolean navimode = true;
	/** ローカルコンテンツ管理システム */
	private RSSLocalContentsMgr lcm = null;

	/** 経路決定ノードとの間でメッセージのやりとりをする遅延同期デーモン */
	private RNodeDSyncMngr dsyncman;
	/** URLエコーの状態 */
	protected boolean flg_EchoURL = true;


	/**
	 * コンストラクタ
	 */
	protected RSSWebNavigator() {
		urls = new URLPool();
		sites = new Hashtable();
		suspender = new URLSuspender();
	}

	/**
	 * 経路ナビゲーターのインスタンスを返す
	 * @return 経路ナビゲーター
	 * @throws IllegalObjectStateException 経路決定ノードとの接続に失敗
	 * @throws RSSIllegalConfigException 経路ナビゲーターの設定ファイルが不正
	 * @throws IOException 設定ファイルの読み込みに失敗
	 */
	public static RSSWebNavigator getNavigator()
					throws IllegalObjectStateException, RSSIllegalConfigException, IOException {

		RSSWebNavigator n = new RSSWebNavigator();

		// 経路ナビゲータ用のコンフィグをロードする
		n.cfg = new RSSNavigatorConfig(NAVIGATIOR_CONF_FILENAME);
		n.navimode = n.cfg.getNavigationMode();

		log.info("RSS探索システムを" + (n.navimode==true?"分散システム":"スタンドアローンシステム") + "として設定します");

		n.maxFetchCnt = n.cfg.getDebugTraceURLs();
		n.flg_EchoURL = n.cfg.getEchoURL();
		n.limKeepLocks = n.cfg.getKeepLocks();
		n.siteLockLastGCTime = Calendar.getInstance().getTimeInMillis();

		n.algo = n.cfg.getRoutingAlgorithm();

		log.info("経路決定アルゴリズムが'" + n.algo.getAlgorithmName() + "'に指定されました");

		try {
			if(n.navimode == false) {
				// 探索開始のURLを登録する処理
				String root = n.cfg.getRoot();
				if(root == null || root.trim().length() == 0) {
					// 'ROOT'の行が存在しない場合
					throw new RSSIllegalConfigException();
				}

				n.urls.add(root);

				// スタンドアローンの場合は探索ノードIDをもっていないので，自分で勝手に作成する
				n.robotId = "SNODE:" + RandomKeyGenerator.getUniqKey();
			} else {
				// 経路決定ノードから探索開始位置を貰う場合
				// まず経路決定ノードへサービスインする
				// 経路決定ノードを選択し，接続用のクライアントスタブを取得する
				log.info("経路決定ノードにサービスインしています");

				Lookup lookup = LookupFactory.getLookup(n.cfg.getDistServRegURL());
				n.rnode = (RNodeClientFactory)lookup.lookup(RSSDistributedServiceRegistry.RSS_SERVID_ROUTING_NODE_CLIENT_FACTORY);
				n.rnClient = n.rnode.bindSearchNode();

				n.robotId = n.rnClient.getNodeId();

				log.info("探索ノードが経路決定ノード[" + n.rnode.getNodeId() +"]にバインドされました");
				log.info("探索ノードID: " + n.robotId);

				// IPアドレス範囲セットを生成する
				IPAddressRangeSet rangeSet = null;

				String httpStubModeString = RSSProperties.getString(RSSConstant.RSS_PKEY_STUB_HTTP_CONNECTION);
				if(httpStubModeString != null && Boolean.valueOf(httpStubModeString).booleanValue() == true) {
					log.info("テスト接続モードのため，IP範囲を制限しません");
				} else {
					rangeSet = IPAddressRangeSetFactory.getIPAddressRangeSet();
					rangeSet.setRanges(n.rnClient.getAllowedRoutingIPRanges());
				}

				// 同期デーモンをスタートさせる
				n.dsyncman = new RNodeDSyncMngr(n.cfg, n.rnClient, 1000, n.urls, rangeSet);
				n.dsyncman.addListener(new SNodeDSyncEventListener(n));
				n.dsyncman.start();
			}
		} catch(MalformedURLException me) {
			log.info("経路決定ノード選択サーバーのURLがおかしい");
			throw new IllegalObjectStateException(me.getMessage());
		} catch(RemoteException re) {
			log.info("経路決定ノードとの通信に失敗しました");
			throw new IllegalObjectStateException(re.getMessage());
		} catch(Exception e) {
			log.info("その他の原因によるエラー : " + e.getMessage());
			e.printStackTrace();
			throw new IllegalObjectStateException(e.getMessage());
		}

		return n;
	}


	/**
	 * ローカルコンテンツ管理システムを設定する
	 * 設定しない場合，ローカルのコンテンツ管理システムは呼ばれない<br>
	 * @param mode true:ローカルコンテンツ管理システムを開始する / false:停止する
	 * @throws RSSIllegalConfigException ローカルコンテンツ管理システムの初期化に失敗
	 */
	public void enableLocalContentsMgr(boolean mode) throws RSSIllegalConfigException {
		if(mode == true) {
			// ローカルコンテンツ管理システムを有効にする場合
			if(lcm == null) {
				// まだローカルコンテンツ管理システムが生成されていない場合
				// 生成する
				lcm = new RSSLocalContentsMgr(navimode);
			}
			// ローカルコンテンツ管理システムのスイッチを入れる
			// これによって，スレッドが開始される
			lcm.start();
		} else if(lcm != null) {
			// ローカルコンテンツ管理システムを停止する
			lcm.done();
		}
	}

	/**
	 * 指定URLの探索権限をロックしているかどうか調べる
	 * ロックされていない場合は，ロックを取得しようと試みる<br>
	 * 結果としてどうしてもロックが取得できない場合はfalseを返す<br>
	 * 既にロックしているか，ロックを取得することが出来た場合はtrueを返す
	 * @param url ロック対象URL
	 * @return true:ロックを取得できた / false:できなかった
	 */
	public boolean isLocked(String url) {
		boolean rc = true;
		String siteName = getSiteNameByURL(url);
		if(siteName == null) {
			return false;
		}

		// 既に十分な数のロックを保有している場合，経路決定ノードへ転送する
		if(dsyncman.findLock(siteName, getRobotId()) == null && dsyncman.numOfMyLocks(getRobotId()) >= limKeepLocks) {
			try {
				dsyncman.poolURL(url);
			} catch(SyncQueueException se) {
			}
			return false;
		}

		// 指定されたサイトに対するロックを確認，状況によっては取得申請してみる
		// 結果として，ロックを保持していないか，あるいは取得できない場合は終了
		if(requireSiteLock(siteName, getRobotId()) == false) {
			log.info(url + "をロックできませんでした");
			return false;
		}

		return rc;
	}

	/**
	 * 指定されたURLを探索しても良いかどうかを調べる
	 * この調査では，robots.txtを参照するだけ<br>
	 * METAタグ指定されているものについては，コンテンツを獲得した段階で判定する
	 * @param url 検査対象URL
	 * @return true:探索OK(robots.txtで許可されている) / false:探索禁止
	 */
	public boolean isAllowed(String url) {
		boolean rc = false;

		String siteName = getSiteNameByURL(url);
		if(siteName == null) {
			return false;
		}

		// サイトオブジェクトの構築
		synchronized(sites) {
			Site so = (Site)sites.get(siteName);
			if(so == null) {
				try {
					log.trace("サイトオブジェクトを作成しています");
					so = Site.getInstance(url, cfg.getProperty("HTTP_USER_AGENT"));
					sites.put(siteName, so);

				} catch(IllegalArgumentException e) {
					// サイトオブジェクトを生成することができなかったので，とりあえず
					// ダメだと判断しておく .. 次回にまた挑戦
					return false;
				}
			}

			if(so.hasRule() == false || so.isAllowed(url, cfg.getHttpUserAgent()) == true) {
				// robots.txtが存在しないか，あったとしても許可されている場合
				rc = true;
			} else {
				// robots.txtが存在して，かつ禁止されている領域の場合
				rc = false;
			}
		}

		log.trace(url + "は，" + (rc==true?"許可":"禁止") + "されています");
		return rc;
	}

	/**
	 * 指定されたサイトに対するロックを確認/申請する
	 * ロックを保持している，あるいは申請の結果として取得できた場合はtrue<br>
　	 * とにかくこの探索ノードでは，扱うことの出来ないサイトの場合はfalseを返す
	 * @param dn ドメイン名
	 * @param rid 探索ノードのID
	 * @return true:ロックの取得に成功 / false:失敗
	 */
	protected boolean requireSiteLock(String dn, String rid) {
		// まずローカルのロック管理オブジェクトを確認する
		if(dsyncman.findLock(dn, rid) != null) {
			return true;
		}

		// ロックを申請してみる
		boolean rc = true;
		SiteLock l = new SiteLock(dn, rid);
		try {
			rc = dsyncman.lock(l);
		} catch(SyncQueueException se) {
			rc = false;
		}

		return rc;
	}

	/**
	 * サイト探索アルゴリズムを設定する
	 * @param r 探索アルゴリズム
	 */
	public void setRoutingAlgorithm(RoutingAlgorithm r) {
		algo = r;
	}

	/**
	 * 探索ノードのIDを返す
	 * @return 探索ノードのID
	 */
	public String getRobotId() {
		return robotId;
	}

	/**
	 * ナビゲータをクローズする
	 * この処理を実行すると，経路決定ノードから探索ノードを切り離す
	 * @throws RemoteException 切断に失敗
	 */
	public void close() throws RemoteException {
		// ローカルコンテンツ管理システムの停止処理
		// 未転送のコンテンツを全てコンテンツ管理システムへ転送する
		if(lcm != null) {
			log.info("ローカルコンテンツ管理システムを停止しています");
			try {
				enableLocalContentsMgr(false);
				log.info("ローカルコンテンツ管理システムを停止しました");
			} catch(Exception e) {
				log.error("停止中に例外が発生しました:\n" + e.getMessage());
			}
		}

		if(navimode == RSSNavigatorConfig.NAVIMODE_STANDALONE) {
			// 単体で動作している場合は何もしないで返す
			return;
		}

		log.info("経路決定ノードから探索ノード'" + getRobotId() + "'を切り離しています");

		if(rnode == null) {
			throw new RemoteException("navigator has been already closed");
		}

		// 切り離す
		rnode.unbindSearchNode(getRobotId());

		log.info("'" + getRobotId() + "'は，経路決定ノードから切り離されました");

		rnode = null;
		rnClient = null;
	}

	/**
	 * 探索の結果，発見されたURLを登録する
	 * 必要に応じてマスターのサーバーへ転送したりもする
	 * @param c Webコンテンツ
	 * @throws IllegalObjectStateException ローカルコンテンツ管理システムで発生した例外
	 * @throws MalformedURLException URLのフォーマットが不正
	 * @throws IOException ローカルコンテンツ管理システムで発生した例外
	 */
	public void sendContents(WebContents c)
									throws IllegalObjectStateException, MalformedURLException, IOException {

		boolean needToStore = true;

		// 一定時間が経過している場合，探索ロック解放のためのGCを起動する
		if(Calendar.getInstance().getTimeInMillis() - siteLockLastGCTime >= cfg.getSiteLockExpire()) {
			log.trace("探索ロックのガベージコレクタを起動しました");
			SiteLock[] removedLockObjs = dsyncman.gc();
			siteLockLastGCTime = Calendar.getInstance().getTimeInMillis();
		}

		if(c == null) {
			// Webコンテンツが含まれていない場合は，何もしないで処理を戻す
			return;
		}

		if(c.getURL().toString().indexOf("robots.txt") >= 0) {
			// 探索の結果，取得したページがrobots.txtだった場合
			releaseSearchLock(c);
			// これ以上の処理は必要ない
			return;
		}

		if(c.getWebContents() == null) {
			// コンテンツがないので，そのまま返す
			return;
		}

		// アンカータグの収集
		Vector anchors = null;
		try {
			anchors = HREFCollector.collect(c.getWebContents());
		} catch(NoIndexException noe) {
			// 該当ページのindexingが禁止されている場合
			if(noe.isFollowable() == true) {
				anchors = noe.getAnchorsInThisPage();		// ※nullの場合もあるので注意

				// webコンテンツを無効化する処理
				needToStore = false;
			}
		}
		Vector an = HREFCollector.getAnchorsAsABSPath(c.getURL(), anchors);
		if(an == null) {
			return;
		}

		int receives = maxFetchCnt == 0 || (an.size() + nowFetchCnt) < maxFetchCnt ? an.size() : maxFetchCnt - nowFetchCnt;

		int nowURLs = urls.size();

		try {
			for(int i = 0; i < receives; i++) {
				if(dsyncman != null && nowURLs >= cfg.getKeepUrls() && navimode == RSSNavigatorConfig.NAVIMODE_DISTRIB) {
					// もうこれ以上URLを保管できない場合は経路決定ノードへ転送するためにプールする
					// ただし，分散システムとして動作している場合のみ．転送処理は同期デーモンが勝手にやる
					dsyncman.poolURL((String)an.elementAt(i));
				} else {
					// 余力があるので，探索ノードで処理する
					urls.add((String)an.elementAt(i));
					nowURLs++;
				}
			}
		} catch(SyncQueueException se) {
		}

		int beforeCnt = nowFetchCnt / 1000;
		nowFetchCnt += receives;
		int afterCnt = nowFetchCnt / 1000;

		// HTMLをプレーンテキストに変換するフィルタの追加
		c.addContentsFilter(new HTMLContentsFilter());

		// コンテンツ管理システムに対して，コンテンツを送出する
		// ただしコンテンツのindexingが許可されているものについてのみ
		if(lcm != null && needToStore == true) {
			synchronized(lcm) {
				while(lcm.isReceivable() == false) {
					try {
						lcm.wait();
					} catch(InterruptedException e) {}
				}
			}

			lcm.store(c);
		}
	}


	/**
	 * 探索抑制状態から復帰する
	 * robots.txtを取得できた場合，サイトオブジェクトに対してルールを設定する
	 * @param c Webコンテンツ
	 * @throws IllegalObjectStateException サイトロックの整合性がおかしい
	 */
	public void releaseSearchLock(WebContents c) throws IllegalObjectStateException {
		// データを元にしてSiteオブジェクトを更新する(ページ取得ルールを設定する)
		String dn = getSiteNameByURL(c.getURL().toString());
		if(dn == null) {
			return;
		}

		Site s = (Site)sites.get(dn);
		if(s == null) {
			// 事前にサイトオブジェクトが生成されていないので，おかしい
			// ※テストモードの場合によく発生するので，とりあえず処理としては継続させる
			log.warn("サイトオブジェクトがないので無視します");
			return;
		}
		String webContents = c.getWebContents();
		if(webContents == null || webContents.trim().length() == 0) {
			// コンテンツを含んでいない場合(404の場合)は何もしない
			log.trace("URL: " + c.getURL().toString() + "は存在しませんでした");
		} else {
			// robots.txtで，何らかのデータを含んでいる場合は展開する
			ByteArrayInputStream in = new ByteArrayInputStream(webContents.getBytes());
			try {
				s.getRuleFromTheSite(in);
				log.trace("サイト'" + dn + "'のルールを設定しました");
			} catch(IOException ie) {
				log.error("robots.txtの解析に失敗");
			}
		}

		// robots.txtが実際にあったかどうかに関わらず，ここで探索抑制状態から復帰させる
		try {
			sites.put(dn, s);
			String[] releasedURLs = suspender.release(dn);
			if(releasedURLs != null) {
				// 解放されたURLがいくつか存在したので，全部キューに戻す
				pushbackURLs(releasedURLs);
			}
			log.trace("サイト'" + dn + "'の探索抑制状態を解放しました");
		} catch(IllegalArgumentException ie) {
			log.trace("サイト'" + dn + "'の解放に失敗しました", ie);
		}
	}

	/**
	 * 次に探索するべきURLを取得する
	 * 取り出したURLは自動的にリストから削除される
	 * @return 次に探索するべきURL
	 */
	public String getNextURL() {
		String rc = null;

		while((rc = algo.nextURL(urls)) != null) {
			// 取り出されたURLが，取得許可されているかどうかを調べる
			// 許可されていない場合，そのURLは捨てる
			try {
				if(suspender.isSuspended(rc) == true) {
					// 探索抑制されているURLなので，探索抑制オブジェクトに登録する(キューからは削除されるが，探索抑制が解除された段階で再入される)
					String dn = getSiteNameByURL(rc);
					if(dn != null) {
						suspender.suspend(Site.getInstance(dn, cfg.getHttpUserAgent()), rc);
					}
				}
			} catch(IllegalArgumentException e) {
				// URLがおかしいので捨てる
				// 何もしないと捨てたことになる
				continue;
			}

			// 取り出されたURLに対応するSiteオブジェクトが存在するかどうかを調べる
			String dn = getSiteNameByURL(rc);
			if(dn == null) {
				// URLがおかしいので捨てる
				continue;
			}

			boolean robotsfg = false;

			// ロックの取得を試す
			if(isLocked(rc) == true) {

				// ロックが取得できたか，あるいは最初から所持している場合
				if(sites.get(dn) == null) {

					// サイトオブジェクトが存在しない場合はrobots.txtを示すURLを代わりとして返す
					Site s = Site.getInstance(rc, cfg.getHttpUserAgent());

					// さらに，生成したサイトオブジェクトを探索抑制管理オブジェクトに登録する
					suspender.suspend(s, rc);

					// 探索履歴から，robots.txtを参照する起点になったURLを削除する
					// このURLをあとで再度探索するために必要になる
					algo.removeFromHistory(rc);

					// 新しくrobots.txtのURLを作成して返す
					rc = s.getRobotsTxtURL();

					// あと，生成したサイトオブジェクトを登録する
					sites.put(dn, s);
					break;
				} else if(isAllowed(rc) == true) {
					// サイトオブジェクトが存在する場合で，探索が許可されている場合
					// これで決定でよいので，ループから脱出する
					break;
				}
			} else {
				// ロックが取得できなかったので，このURLはなかったことにする
				// 何もしないと捨てたことになる
				// 必要であれば，ここに別の探索ノードへの転送処理を入れても良い
			}
		}

		return rc;
	}

	/**
	 * URLをプッシュバックする
	 * @param u プッシュバックしたいURLの配列
	 */
	private void pushbackURLs(String[] u) {
		if(u == null) {
			return;
		}

		for(int i = 0; i < u.length; i++) {
			try {
				urls.add(u[i]);
			} catch(MalformedURLException ue) {
				// 追加しようとしたURLに何か問題があった場合は無視する
			}
		}
	}

	/**
	 * 未処理URLの残量を返す
	 * @return 未処理URLの残数
	 */
	public int getQueueSize() {
		return urls.size();
	}

	/**
	 * 未処理URLが0の間はスレッドを停止する
	 */
	public void waitUntilEmpty() {
		urls.waitUntilEmpty();
	}

	/**
	 * この経路ナビゲータが既に機能を停止しているかどうかを返す
	 * 指定された数だけURLを格納し，それらを全てロボットが探索した場合は停止状態になる
	 * @return true:経路ナビゲータは停止可能 / false:実行中
	 */
	public boolean isFinished() {
		if(shutdownFlg == true) {
			return true;
		}
		if(countSuspendedSites() + urls.size() == 0 && maxFetchCnt > 0 && nowFetchCnt >= maxFetchCnt) {
			// 探索抑制状態URLとキューが空っぽで，かつ格納上限値が設定されていて，その数を処理した場合
			return true;
		} else {
			// それ以外
			return false;
		}
	}

	/**
	 * 探索抑制状態のサイトがあるかどうかを調べる
	 * @return 探索抑制状態のサイト数
	 */
	public int countSuspendedSites() {
		return suspender.size();
	}

	/**
	 * 完全なURLからサイト名を抽出する
	 * @param url URL
	 * @return ドメイン名
	 */
	private String getSiteNameByURL(String url) {
		// 完全なURLから，ドメイン名の部分だけを切り出す
		try {
			URL u = new URL(url);
			return u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
		} catch(MalformedURLException e) {
			return null;
		}
	}

	/**
	 * ナビゲーションモードを返す
	 * @return true:分散システム / false:standalone
	 */
	public boolean getNavigationMode() {
		return navimode;
	}
	/**
	 * 経路ナビゲーターを強制的にシャットダウンする
	 */
	public void shutdown() {
		shutdownFlg = true;
	}
}

// end of RSSWebNavigator.java
