/*
 * Copyright (c) 2016 The EUROMAP63.jp 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.fileexec;

import java.util.function.Consumer;
import java.util.Locale;
import java.util.List;
import java.util.ArrayList;
import java.nio.file.Path;
import java.nio.charset.Charset;
import static java.nio.charset.StandardCharsets.UTF_8;

/**
 * LineSplitter は、１行分のデータを順次分割するクラスです。
 *
 *<pre>
 * ファイルは、『改行』で行分割して、カンマかタブでカラム分割します。
 *
 *  応答ファイルの解析処理を簡素化するため、以下のルール(禁止事項)を定めます。
 *      １．ダブルクオートの中に、ダブルクオート、改行、を含まないこと。
 *          （カンマとスペースは含めることが出来ます。）
 *      ２．１行の定義は、『改行』とします。
 *      ３．スペース分割時は、複数スペースの場合でも、１つの区切り文字として扱います。
 *          （A B    C D → ｢A｣、｢B｣、｢C｣、｢D｣ に分割されます。）
 *      ４．カンマ分割は、ダブルクオート間のカンマは分解しません。
 *          混在した場合でも、最初に見つけた方が優先されます。
 *      ５．カンマ分割時は、複数カンマの場合は、それぞれ空文字列に分割されます。
 *          カンマ分割後、それぞれの文字列は、前後スペースを削除(trim)します。
 *          （A, B , ,  C , D → ｢A｣、｢B｣、｢｣、｢C｣、｢D｣ に分割されます。）
 *      ６．カラム分解後の、ダブルクオートは、削除します。
 *          （（A, B , ,  "CC C,C" , D → ｢A｣、｢B｣、｢｣、｢CC C,C｣、｢D｣ に分割されます。）
 *
 *  処理手順
 *      １．ファイルより、１行づつ（改行コードで分割）読み込みます。
 *      ２．読み込んだ１行について、先頭が、『#』の行はコメント行としてスキップします。
 *      ３．先頭から、区切り文字(スペースかカンマかタブ)が見つかるまでを、１カラムとして取得します。
 *      ４．その間、ダブルクオートが見つかったら、次のダブルクオートまで、取り込みます。
 *      ５．カラム分割された単語の前後スペースと、前後ダブルクオートを削除します。
 *          trim()が先で、ダブルクオートの削除は、後から行います。(ダブルクオート内のtrim()は行いません。)
 *      ６．個々のカラムを配列にして返します。
 *      ７．これを、ファイルが終了するまで繰り返します。
 *
 * 並行性
 *  このクラスは、staticメソッドのみのユーティリティークラスのため、スレッドに対して、安全です。
 *  また、ﾌｧｲﾙの読み取りに関して、FileChannelのtryLockを行っています。
 *</pre>
 *
 * @og.rev 1.0.0 (2016/04/28) 新規作成
 *
 * @version  1.0
 * @author   Kazuhiko Hasegawa
 * @since    JDK1.8,
 */
public final class LineSplitter {
	private static final XLogger LOGGER= XLogger.getLogger( LineSplitter.class.getSimpleName() );		// ログ出力

	private final Charset chset ;				// ファイルのエンコード
	private String[] clms = new String[0];		// オリジナルのカラム列(ゼロ文字列も含む)

	private static final String NAME_KEY = "#NAME";
	private static final int    NAME_LEN = NAME_KEY.length();

	/**
	 * デフォルトコンストラクター
	 *
	 * ファイル読み取りのCharsetは、UTF-8になります。
	 * @see	java.nio.charset.StandardCharsets#UTF_8
	 */
	public LineSplitter() {
		this( UTF_8 , null );
	}

	/**
	 * Charsetに対応した文字列を指定して、オブジェクトを作成します。
	 *
	 * @param chStr		ファイルを読み取るときのCharset文字列
	 * @param inClms	外部指定カラム文字列(CSV形式)
	 */
	public LineSplitter( final String chStr , final String inClms ) {
		this( Charset.forName( chStr ) , inClms );
	}

	/**
	 * Charsetを指定して、オブジェクトを作成します。
	 *
	 * @param chObj		ファイルを読み取るときのCharsetオブジェクト
	 * @param inClms	外部指定カラム文字列(CSV形式)
	 */
	public LineSplitter( final Charset chObj , final String inClms ) {
		chset = chObj;
		if( inClms != null && !inClms.isEmpty() ) {
			clms = inClms.split( "," );			// CSV形式のカラム列を一旦分解します。
		}
		LOGGER.debug( () -> "[LineSplitter] Charset=" + chObj + " , inClms=" + inClms );
	}

	/**
	 * #NAME が存在すれば、そこから名前配列を返します。
	 *
	 * ここでは、オリジナルのカラム列(ゼロ文字列も含む)ではなく、
	 * 存在するカラム名だけのカラム列を返します。
	 *
	 * 外部指定カラムがあれば、そちらを優先します。
	 * 無ければ、長さゼロの配列 が返されます。
	 *
	 * @return あれば名前配列、無ければ、長さゼロの配列
	 */
	public String[] getColumns() {
		final List<String> clmList = new ArrayList<>();		// １行分の分割したトークンのリスト
		for( final String clm : clms ) {
			if( !clm.isEmpty() ) { clmList.add( clm ); }	// ゼロ文字列以外のカラムのみ登録します。
		}
		return clmList.toArray( new String[clmList.size()] );
	}

	/**
	 * １行づつ処理を行った結果のトークンをConsumerにセットする繰り返しメソッドです。
	 * １行単位に、Consumer#action が呼ばれます。
	 * セットされるリストは、１行をトークンに分割したリストで、空行の場合は、SKIPします。
	 * また、オリジナルのカラム列がゼロ文字列の場合は、その列データを返しません。
	 * つまり、存在するカラム名だけの値列を返します。
	 *
	 * ファイルを順次読み込むため、内部メモリを圧迫しません。
	 *
	 * @param inPath	処理対象のPathオブジェクト
	 * @param action	行を区切り文字で分割した文字列のリストを引数に取るConsumerオブジェクト
	 * @throws	RuntimeException ファイル読み込み時にエラーが発生した場合
	 * @see		FileUtil#lockForEach(Path,Consumer)
	 */
	public void forEach( final Path inPath , final Consumer<List<String>> action ) {
		FileUtil.lockForEach(
			inPath ,
			chset  ,
			line -> {
				final List<String> list = split( line );			// 行末カット、コメントカット、trim されます。
				if( !list.isEmpty() ) { action.accept( list ); }	// 空のリストオブジェクトの場合、SKIPします。
			}
		);
	}

	/**
	 * １行分の分割したトークンのリストを返します。
	 *
	 * ファイルの読み込みを、単独または、別に行った場合に、１行データとして、処理できます。
	 * このクラスの特徴である、先頭が、『#』の行は、コメントとみなして、削除します。
	 * １行分をtrim()する処理も、行います。
	 * trim()の結果が、空文字列のみの場合は、空のリストオブジェクトを返します。
	 *
	 * @og.rev 7.2.1.0 (2020/03/13) カラム列がデータより少ない場合の対応
	 *
	 * @param	orgLine １行データ(オリジナル)
	 * @return	１行分の分割したトークンのリスト(行末、コメント、trim処理済み)
	 */
	public List<String> split( final String orgLine ) {
		final List<String> list = new ArrayList<>();			// １行分の分割したトークンのリスト

		final String line = cmntCut( orgLine );					// 行末カット、コメントカット、trim されます。
		if( line.isEmpty() ) { return list; }					// Zero文字列の場合、空のリストオブジェクトを返します。

		final int maxPosition = line.length();
		int currentPosition = 0;
		int listNo = 0;

		// "<" では末尾の項目が空(カンマで1行が終わる)場合、正しく処理できない。
		while( currentPosition <= maxPosition ) {
	//		boolean fstSpace = true;		// 先頭にスペースがある場合は、削除します
			boolean inquote  = false;		// ダブルクオート内部のカンマは、スキップする。
	//		boolean inkakko  = false;		// 『[』と『]』の間のカンマは、スキップする。

			int position = currentPosition;
			final int from = position;

			char ch = 0;
			while( position < maxPosition ) {
				ch = line.charAt( position );
	//			if( fstSpace ) {
	//				if( ch <= ' ' ) { position++ ; continue; }		// UTF-8 で、' ' より小さい文字は、空白文字(trim対象)です。
	//				fstSpace = false;
	//				from = position ;		// 最初に見つけた、空白文字以外の文字の位置
	//			}

	//			if( !inquote && !inkakko && ( ch == ',' || ch <= ' ') ) { break; }
				if( !inquote && ( ch == ',' || ch <= ' ') ) { break; }
				else if( '"' == ch ) { inquote = !inquote; }					// 『"』クオート処理を行う
	//			else if( '[' == ch && !inkakko ) { inkakko = true;  }			// 『[』の開始処理
	//			else if( ']' == ch && inkakko  ) { inkakko = false; }			// 『]』の終了処理
				position++;
			}

			// 分割トークン
			String token = line.substring( from,position );

			// トークンの前後が '"' なら、削除します。
			final int len = token.length();

			if( len >=2 && token.charAt(0) == '"' && token.charAt(len-1) == '"' ) {
				token = token.substring( 1,len-1 );
			}

			// 超特殊処理。tokenにnullは含まれないが、文字列が、"null"の場合は、ゼロ文字列と置き換えます。
			if( "null".equalsIgnoreCase( token ) ) { token = ""; }

			//  #NAME 列を削除します。       ゼロカラム列を削除します。
//			if( !clms[listNo].isEmpty() ) {
			if( listNo < clms.length && !clms[listNo].isEmpty() ) {		// 7.2.1.0 (2020/03/13)
				list.add( token );
			}
			listNo++ ;

			// ch の終わり方で、スペースの場合、CSV形式かも知れないので、もう少し様子を見る。
			while( ch <= ' ' && ++position < maxPosition ) {
				ch = line.charAt( position );
			}

			// 最後がカンマでなければ、進めすぎている。
			currentPosition = ch == ',' || position == maxPosition ? position+1 : position ;
		}

		return list;
	}

	/**
	 * 先頭文字が、'#' の行を削除した文字列を返します。
	 *
	 * このメソッド上で、#NAME があれば、カラム配列を作成します。
	 * カラム配列は、最初の一度のみ、セット可能とします。
	 *
	 * @param	line １行分の文字列(not null)
	 * @return	コメント削除後の行
	 * @throws	NullPointerException 引数lineが、nullの場合。
	 */
	public String cmntCut( final String line ) {
		final boolean isCmnt = !line.isEmpty() && line.charAt(0) == '#';

		// カラム配列は、最初の一度のみ、セット可能とします。
		if( isCmnt && clms.length == 0 && line.toUpperCase( Locale.JAPAN ).startsWith( NAME_KEY ) ) {
			final String sep = line.substring( NAME_LEN,NAME_LEN+1 );		// 区切り文字。タブかカンマ
			clms = line.split( sep );										// 区切り文字で配列化します。
			clms[0] = "";													// 統一的に処理を行うため、#NAME を、ゼロ文字列化します。
		}

		return isCmnt ? "" : line;
	}
}
