/*
 * Copyright (c) 2009 The openGion Project.
 *
 * 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.
 */
package org.opengion.fukurou.xml;

import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;

import org.opengion.fukurou.util.StringUtil;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

/**
 * XML2TableParser は、XMLを表形式に変換するためのXMLパーサーです。
 * XMLのパースには、SAXを採用しています。
 *
 * このクラスでは、XMLデータを分解し、2次元配列の表データ、及び、指定されたキーに対応する
 * 属性データのマップを生成します。
 *
 * これらの配列を生成するためには、以下のパラメータを指定する必要があります。
 *
 * ①2次元配列データ(表データ)の取り出し
 *   行のキー(タグ名)と、項目のキー一覧(タグ名)を指定することで、表データを取り出します。
 *   具体的には、行キーのタグセットを"行"とみなし、その中に含まれる項目キーをその列の"値"と
 *   して分解されます。(行キーがN回出現すれば、N行が生成されます。)
 *   もし、行キーの外で、項目キーのタグが出現した場合、その項目キーのタグは無視されます。
 *
 *   また、colKeysにPARENT_TAG、PARENT_FULL_TAGを指定することで、rowKeyで指定されたタグの
 *   直近の親タグ、及びフルの親タグ名(親タグの階層を"&gt;[タグA]&gt;[タグB]&gt;[タグC]&gt;"で表現)を
 *   取得することができます。
 *
 *   行キー及び項目キーは、{@link #setTableCols(String, String[])}で指定します。
 *
 * ②属性データのマップの取り出し
 *   属性キー(タグ名)を指定することで、そのタグ名に対応した値をマップとして生成します。
 *   同じタグ名が複数回にわたって出現した場合、値はアペンドされます。
 *
 *   属性キーは、{@link #setReturnCols(String[])}で指定します。
 *
 * ※それぞれのキー指定は、大文字、小文字を区別した形で指定することができます。
 *   但し、XMLのタグ名とマッチングする際は、大文字、小文字は区別せずにマッチングされます。
 *
 * @version  4.0
 * @author   Hiroki Nakamura
 * @since    JDK5.0,
 */
public class XML2TableParser extends DefaultHandler {

	/*-----------------------------------------------------------
	 *  表形式パース
	 *-----------------------------------------------------------*/
	// 表形式パースの変数
	String rowCpKey = "";
	String colCpKeys = "";
	Map<String,Integer> colCpIdxs = new HashMap<String, Integer>();

	// 表形式出力データ
	List<String[]> rows = new ArrayList<String[]>();
	String[] data;
	String[] cols;

	/*-----------------------------------------------------------
	 *  Map型パース
	 *-----------------------------------------------------------*/
	// Map型パースの変数
	String rtnCpKeys = "";

	// Map型出力データ
	Map<String,String> rtnKeyMap = new HashMap<String, String>();
	Map<String,String> rtnMap	 = new HashMap<String, String>();

	/*-----------------------------------------------------------
	 *  パース中のタグの状態定義
	 *-----------------------------------------------------------*/
	boolean isInRow		;		// rowKey中に入る間のみtrue
	String curQName		= "";	// パース中のタグ名    ( [タグC]             )
	String curFQName	= "";	// パース中のフルタグ名( [タグA]>[タグB]>[タグC] )

	// 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
	private static final String PARENT_FULL_TAG_KEY = "PARENT_FULL_TAG";
	private static final String PARENT_TAG_KEY		= "PARENT_TAG";

	int pFullTagIdx = -1;
	int pTagIdx		= -1;

	/*-----------------------------------------------------------
	 *  href、IDによるデータリンク対応
	 *-----------------------------------------------------------*/
	String curId = "";
	List<RowColId> idList	 = new ArrayList<RowColId>();		// row,colとそのIDを記録
	Map<String,String> idMap = new HashMap<String,String>();	// col__idをキーに値のマップを保持

	final InputStream input;

	/**
	 * XMLの文字列を指定してパーサーを形成します。
	 *
	 * @param st XMLデータ(文字列)
	 */
	public XML2TableParser( final String st ) {
		byte[] bts = null;
		try {
			bts = st.getBytes( "UTF-8" );
		}
		catch( UnsupportedEncodingException ex ) {
			final String errMsg = "不正なエンコードが指定されました。エンコード=[UTF-8]"  ;
			throw new RuntimeException( errMsg , ex );
		}
		// XML宣言の前に不要なデータがあれば、取り除きます。
		final int offset = st.indexOf( '<' );
		input = new ByteArrayInputStream( bts, offset, bts.length - offset  );
	}

	/**
	 * ストリームを指定してパーサーを形成します。
	 *
	 * @param is XMLデータ(ストリーム)
	 */
	public XML2TableParser( final InputStream is ) {
		input = is;
	}

	/**
	 * 2次元配列データ(表データ)の取り出しを行うための行キーと項目キーを指定します。
	 *
	 * @og.rev 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
	 * @og.rev 5.1.9.0 (2010/08/01) 可変オブジェクトへの参照の直接セットをコピーに変更
	 *
	 * @param rKey 行キー
	 * @param cKeys 項目キー配列(可変長引数)
	 */
	public void setTableCols( final String rKey, final String... cKeys ) {
		// 6.1.1.0 (2015/01/17) 可変長引数でもnullは来る。
		if( rKey == null || rKey.isEmpty() || cKeys == null || cKeys.length == 0 ) {
			return;
		}
		cols = cKeys.clone();		// 5.1.9.0 (2010/08/01)
		rowCpKey = rKey.toUpperCase( Locale.JAPAN );
		colCpKeys = "," + StringUtil.array2csv( cKeys ).toUpperCase( Locale.JAPAN ) + ",";

		for( int i = 0; i < cols.length; i++ ) {
			final String tmpKey = cols[i].toUpperCase( Locale.JAPAN );
			// 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
			if( PARENT_TAG_KEY.equals( tmpKey ) ) {
				pTagIdx = Integer.valueOf( i );
			}
			else if( PARENT_FULL_TAG_KEY.equals( tmpKey ) ) {
				pFullTagIdx = Integer.valueOf( i );
			}
			colCpIdxs.put( tmpKey, Integer.valueOf( i ) );
		}
	}

	/**
	 * 属性データのマップの取り出しを行うための属性キーを指定します。
	 *
	 * @param rKeys 属性キー配列(可変長引数)
	 */
	public void setReturnCols( final String... rKeys ) {
		// 6.1.1.0 (2015/01/17) 可変長引数は、nullは来ないので、ロジックを組みなおします。
		if( rKeys.length > 0 ) {
			rtnCpKeys = "," + StringUtil.array2csv( rKeys ).toUpperCase( Locale.JAPAN ) + ",";
			for( int i = 0; i < rKeys.length; i++ ) {
				rtnKeyMap.put( rKeys[i].toUpperCase( Locale.JAPAN ), rKeys[i] );
			}
		}
	}

	/**
	 * 表データのヘッダーの項目名を配列で返します。
	 *
	 * @og.rev 5.1.9.0 (2010/08/01) 可変オブジェクトの参照返しをコピー返しに変更
	 *
	 * @return 表データのヘッダーの項目名の配列
	 */
	public String[] getCols() {
		return (cols == null) ? null : cols.clone();	// 5.1.9.0 (2010/08/01)
	}

	/**
	 * 表データを2次元配列で返します。
	 *
	 * @return 表データの2次元配列
	 * @og.rtnNotNull
	 */
	public String[][] getData() {
		return rows.toArray( new String[rows.size()][0] );
	}

	/**
	 * 属性データをマップ形式で返します。
	 *
	 * @return 属性データのマップ
	 */
	public Map<String,String> getRtn() {
		return rtnMap;
	}

	/**
	 * XMLのパースを実行します。
	 */
	public void parse() {
		final SAXParserFactory spfactory = SAXParserFactory.newInstance();
		try {
			final SAXParser parser = spfactory.newSAXParser();
			parser.parse( input, this );
		}
		catch( ParserConfigurationException ex ) {
			throw new RuntimeException( "パーサーの設定に問題があります。", ex );
		}
		catch( SAXException ex ) {
			throw new RuntimeException( "パースに失敗しました。", ex );
		}
		catch( IOException ex ) {
			throw new RuntimeException( "データの読み取りに失敗しました。", ex );
		}
	}

	/**
	 * 要素の開始タグ読み込み時に行う処理を定義します。
	 *
	 * @og.rev 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
	 *
	 * @param	uri			名前空間ＵＲＩ。要素が名前空間 ＵＲＩを持たない場合、または名前空間処理が行われない場合は空文字列
	 * @param	localName	接頭辞を含まないローカル名。名前空間処理が行われない場合は空文字列
	 * @param	qName		接頭辞を持つ修飾名。修飾名を使用できない場合は空文字列
	 * @param	attributes	要素に付加された属性。属性が存在しない場合、空の Attributesオブジェクト
	 */
	@Override
	public void startElement( final String uri, final String localName, final String qName, final Attributes attributes ) {

		// 処理中のタグ名を設定します。
		curQName = getCpTagName( qName );

		if( rowCpKey.equals( curQName ) ) {
			isInRow = true;
			data = new String[cols.length];
			// 5.1.6.0 (2010/05/01) rowKeyの親タグが取得できるように対応
			if( pTagIdx >= 0 ) { data[pTagIdx] = getCpParentTagName( curFQName ); }
			if( pFullTagIdx >= 0 ) { data[pFullTagIdx] = curFQName; }
		}

		curFQName += ">" + curQName + ">";

		// href属性で、ID指定(初めが"#")の場合は、その列番号、行番号、IDを記憶しておきます。(後で置き換え)
		final String href = attributes.getValue( "href" );
		if( href != null && href.length() > 0 && href.charAt(0) == '#' ) {
			// 6.0.2.5 (2014/10/31) refactoring
			final int colIdx = getColIdx( curQName );
			if( isInRow && colIdx >= 0 ) {
				idList.add( new RowColId( rows.size(), colIdx, href.substring( 1 ) ) );
			}
		}

		// id属性を記憶します。
		curId = attributes.getValue( "id" );
	}

	/**
	 * href属性を記憶するための簡易ポイントクラスです。
	 */
	private static final class RowColId {
		final int row;
		final int col;
		final String id;

		/**
		 * 行、列、idキーを引数に取るコンストラクター
		 *
		 * @param	rw	行
		 * @param	cl	列
		 * @param	st	idキー
		 */
		RowColId( final int rw, final int cl, final String st ) {
			row = rw; col = cl; id = st;
		}
	}

	/**
	 * テキストデータ読み込み時に行う処理を定義します。
	 *
	 * @param	ch		文字データ配列
	 * @param	offset	文字配列内の開始位置
	 * @param	length	文字配列から使用される文字数
	 */
	@Override
	public void characters( final char[] ch, final int offset, final int length ) {
		final String val = new String( ch, offset, length );
		// 6.0.2.5 (2014/10/31) refactoring
		final int colIdx = getColIdx( curQName );

		// 表形式データの値をセットします。
		if( isInRow && colIdx >= 0 ) {
			data[colIdx] = ( data[colIdx] == null ? "" : data[colIdx] ) + val;
		}

		// 属性マップの値を設定します。
		// 5.1.6.0 (2010/05/01)
		if( curQName != null && curQName.length() > 0 && rtnCpKeys.indexOf( curQName ) >= 0 ) {
			final String key = rtnKeyMap.get( curQName );
			final String curVal = rtnMap.get( key );
			rtnMap.put( key, ( curVal == null ? "" : curVal ) + val );
		}

		// ID属性が付加された要素の値を取り出し、保存します。
		if( curId != null && curId.length() > 0  && colIdx >= 0 ) {
			final String curVal = rtnMap.get( colIdx + "__" + curId );
			idMap.put( colIdx + "__" + curId, ( curVal == null ? "" : curVal ) + val );
		}
	}

	/**
	 * 要素の終了タグ読み込み時に行う処理を定義します。
	 *
	 * @param	uri			名前空間 URI。要素が名前空間 URI を持たない場合、または名前空間処理が行われない場合は空文字列
	 * @param	localName	接頭辞を含まないローカル名。名前空間処理が行われない場合は空文字列
	 * @param	qName		接頭辞を持つ修飾名。修飾名を使用できない場合は空文字列
	 */
	@Override
	public void endElement( final String uri, final String localName, final String qName ) {
		curQName = "";
		curId = "";

		// 表形式の行データを書き出します。
		final String tmpCpQName = getCpTagName( qName );
		if( rowCpKey.equals( tmpCpQName ) ) {
			rows.add( data );
			isInRow = false;
		}

		curFQName = curFQName.replace( ">" + tmpCpQName + ">", "" );
	}

	/**
	 * ドキュメント終了時に行う処理を定義します。
	 *
	 */
	@Override
	public void endDocument() {
		// hrefのIDに対応する値を置き換えます。
		for( final RowColId rci : idList ) {
			rows.get( rci.row )[rci.col] = idMap.get( rci.col + "__" + rci.id );
		}
	}

	/**
	 * PREFIXを取り除き、さらに大文字かしたタグ名を返します。
	 *
	 * @param qName PREFIX付きタグ名
	 *
	 * @return PREFIXを取り除いた大文字のタグ名
	 */
	private String getCpTagName( final String qName ) {
		String tmpCpName = qName.toUpperCase( Locale.JAPAN );
		// 6.0.2.5 (2014/10/31) refactoring
		final int preIdx = tmpCpName.indexOf( ':' );
		if( preIdx >= 0 ) {
			tmpCpName = tmpCpName.substring( preIdx + 1 );
		}
		return tmpCpName;
	}

	/**
	 * >[タグC]>[タグB]>[タグA]>と言う形式のフルタグ名から[タグA](直近の親タグ名)を
	 * 取り出します。
	 *
	 * @og.rev 5.1.9.0 (2010/08/01) 引数がメソッド内部で使用されていなかったため、修正します。
	 *
	 * @param fQName フルタグ名
	 *
	 * @return 親タグ名
	 */
	private String getCpParentTagName( final String fQName ) {
		String tmpPQName = "";

		final int curNStrIdx = fQName.lastIndexOf( '>', fQName.length() - 2 ) + 1;	// 6.0.2.5 (2014/10/31) refactoring
		final int curNEndIdx = fQName.length() - 1;
		if( curNStrIdx >= 0 && curNEndIdx >= 0 && curNStrIdx < curNEndIdx ) {
			tmpPQName = fQName.substring( curNStrIdx, curNEndIdx );
		}
		return tmpPQName;
	}

	/**
	 * タグ名に相当するカラムの配列番号を返します。
	 *
	 * @og.rev 5.1.6.0 (2010/05/01) colKeysで指定できない項目が存在しない場合にエラーとなるバグを修正
	 *
	 * @param	tagName	タグ名
	 *
	 * @return 配列番号(存在しない場合は、-1)
	 */
	private int getColIdx( final String tagName ) {
		int idx = -1;
		if( tagName != null && tagName.length() > 0 && colCpKeys.indexOf( tagName ) >= 0 ) {
			// 5.1.6.0 (2010/05/01)
			final Integer key = colCpIdxs.get( tagName );
			if( key != null ) {
				idx = key.intValue();
			}
		}
		return idx;
	}
}
