/*
 * 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.db;

import java.util.List;
import java.util.ArrayList;
import java.util.Locale ;
import java.util.Arrays ;
import java.util.Set ;
import java.util.HashSet ;
import java.util.LinkedHashSet ;
import java.util.StringJoiner ;

import org.opengion.fukurou.util.StringUtil;
import org.opengion.fukurou.system.OgBuilder ;
import org.opengion.fukurou.system.OgRuntimeException ;
import static org.opengion.fukurou.system.HybsConst.CR;
import static org.opengion.fukurou.system.HybsConst.BUFFER_MIDDLE;

/**
 * QueryMaker は、カラム名などから、SELECT,INSERT,UPDATE,DALETE 文字列を作成するクラスです。
 *
 * 基本的には、カラム名と、それに対応する値のセットで、QUERY文を作成します。
 * 値には、[カラム名] が使用でき、出力される値として、? が使われます。
 * これは、PreparedStatement に対する引数で、処理を行うためです。
 * この[カラム名]のカラム名は、検索された側のカラム名で、INSERT/UPDATE/DELETE等が実行される
 * ﾃﾞｰﾀﾍﾞｰｽ（ﾃｰﾌﾞﾙ)のカラム名ではありません。(偶然、一致しているかどうかは別として)
 *
 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
 *
 * @version  6.8.6.0 (2018/01/19)
 * @author	 Kazuhiko Hasegawa
 * @since    JDK6.0,
 */
public class QueryMaker {
	private static final String QUERY_TYPE = "SELECT,INSERT,UPDATE,DELETE,MERGE" ;

	private final List<String> whrList = new ArrayList<>() ;	// where条件に含まれる [カラム名] のリスト(パラメータ一覧)

	private String queryType ;		// QUERYタイプ(SELECT,INSERT,UPDATE,DELETE,MERGE) を指定します。
	private String table ;
	private String names ;
	private String omitNames ;
	private String where ;
	private String whrNames ;
	private String orderBy ;
	private String cnstKeys ;
	private String cnstVals ;

	private int		clmLen;			// names カラムの "?" に置き換えられる個数
	private boolean isSetup ;		// セットアップ済みを管理しておきます。
	private String[] nameAry;

	/**
	 * デフォルトコンストラクター
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 */
	public QueryMaker() { super(); }		// これも、自動的に呼ばれるが、空のメソッドを作成すると警告されるので、明示的にしておきます。

	/**
	 * 処理の前に、入力データの整合性チェックや、初期設定を行います。
	 *
	 * あまり、何度も実行したくないので、フラグ管理しておきます。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 * @og.rev 6.9.0.2 (2018/02/13) omitNamesの対応
	 * @og.rev 6.9.8.0 (2018/05/28) setup() は、内部処理からのみ呼ばれるので、private化します。
	 */
//	public void setup() {
	private void setup() {
		if( isSetup ) { return; }		// セットアップ済み

		if( StringUtil.isNull( table ) ) {
			final String errMsg = "指定の table に、null、ゼロ文字列は指定できません。"
							+ " table=" + table ;
			throw new OgRuntimeException( errMsg );
		}

		if( StringUtil.isNull( names ) ) {
			final String errMsg = "指定の names に、null、ゼロ文字列は指定できません。"
							+ " names=" + names ;
			throw new OgRuntimeException( errMsg );
		}

		// 6.9.0.2 (2018/02/13) omitNamesの対応
		final String[]    nmAry  = StringUtil.csv2Array( names );
		final Set<String> nmSet  = new LinkedHashSet<>( Arrays.asList( nmAry ) );		// names の順番は、キープします。
		final String[]    omtAry = StringUtil.csv2Array( omitNames );
		final Set<String> omtSet = new HashSet<>( Arrays.asList( omtAry ) );			// 除外する順番は、問いません。
		nmSet.removeAll( omtSet );

		// 初期設定
		clmLen  = nmSet.size();
		nameAry = nmSet.toArray( new String[clmLen] );

//		// 初期設定
//		nameAry = StringUtil.csv2Array( names );
//		clmLen = nameAry.length;

		// [カラム名] List は、whereNames + where の順番です。(whrListの登録順を守る必要がある)
		// where条件も、この順番に連結しなければなりません。
		where = StringUtil.join( " AND " , whrNames , formatSplit( where ) );	// formatSplit で、whrListの登録を行っている。

		isSetup = true;
	}

	/**
	 * データを検索する場合に使用するSQL文を作成します。
	 *
	 * SELECT names FROM table WHERE where ORDER BY orderBy ;
	 *
	 * cnstKeys,cnstVals は、使いません。
	 * where,orderBy は、それぞれ、値が存在しない場合は、設定されません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 * @og.rev 6.9.0.2 (2018/02/13) omitNamesの対応
	 *
	 * @return  検索SQL
	 * @og.rtnNotNull
	 */
	public String getSelectSQL() {
		if( !"SELECT".equals( queryType ) ) {
			final String errMsg = "指定のQUERYタイプと異なるSQL文を要求しています。" + CR
							+ " 要求SQL=SELECT  queryType=" + queryType ;
			throw new OgRuntimeException( errMsg );
		}

		setup();

		return new OgBuilder()
//			.append(   "SELECT "    , names )
			.append(   "SELECT "            )
			.join(     ","          , nameAry )		// 6.9.0.2 (2018/02/13) names ではなく、omitNames後のカラム配列を使用します。
			.append(   " FROM "     , table )
			.appendNN( " WHERE "    , where )		// nullなら、追加しない。where + whereNames
			.appendNN( " ORDER BY " , orderBy )		// nullなら、追加しない。
			.toString();
	}

	/**
	 * データを追加する場合に使用するSQL文を作成します。
	 *
	 * INSERT INTO table ( names,cnstKeys ) VALUES ( values,cnstVals ) ;
	 *
	 * cnstKeys,cnstVals は、INSERTカラムとして使います。
	 * where,orderBy は、使いません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 * @og.rev 6.9.0.2 (2018/02/13) omitNamesの対応
	 *
	 * @return  追加SQL
	 * @og.rtnNotNull
	 */
	public String getInsertSQL() {
		if( !"INSERT".equals( queryType ) && !"MERGE".equals( queryType ) ) {
			final String errMsg = "指定のQUERYタイプと異なるSQL文を要求しています。" + CR
							+ " 要求SQL=INSERT  queryType=" + queryType ;
			throw new OgRuntimeException( errMsg );
		}

		setup();

		return new OgBuilder()
			.append( "INSERT INTO " ).append( table )
//			.append( " ( " ).append( names )
			.append( " ( " )
			.join(     "," , nameAry  )			// 6.9.0.2 (2018/02/13) names ではなく、omitNames後のカラム配列を使用します。
			.appendNN( "," , cnstKeys )
			.append( " ) VALUES ( " )
			.appendRoop( 0,clmLen,",",i -> "?" )
			.appendNN( "," , cnstVals )
			.append( " )" )
			.toString();
	}

	/**
	 * データを更新する場合に使用するSQL文を作成します。
	 *
	 * UPDATE table SET names[i]=values[i], ･･･cnstKeys[i]=cnstVals[i], ･･･ WHERE where;
	 *
	 * cnstKeys,cnstVals は、UPDATEカラムとして使います。
	 * orderBy は、使いません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 * @og.rev 6.9.9.1 (2018/08/27) cnstKeys,cnstValsは、個数違いの場合のみ、エラーです。
	 *
	 * @return  更新SQL
	 * @og.rtnNotNull
	 */
	public String getUpdateSQL() {
		if( !"UPDATE".equals( queryType ) && !"MERGE".equals( queryType ) ) {
			final String errMsg = "指定のQUERYタイプと異なるSQL文を要求しています。" + CR
							+ " 要求SQL=UPDATE  queryType=" + queryType ;
			throw new OgRuntimeException( errMsg );
		}

		setup();

		final String[] cnKey = StringUtil.csv2Array( cnstKeys );	// @og.rtnNotNull
		final String[] cnVal = StringUtil.csv2Array( cnstVals );	// @og.rtnNotNull

		// 整合性チェック
		// 6.9.8.0 (2018/05/28) FindBugs:null でないことがわかっている値の冗長な null チェック
//		if( cnKey != null && cnVal == null ||
//			cnKey == null && cnVal != null ||
//			cnKey != null && cnVal != null && cnKey.length != cnVal.length ) {
		// 6.9.9.1 (2018/08/27) cnstKeys,cnstValsは、個数違いの場合のみ、エラーです。
//		if( cnKey.length == 0 || cnVal.length == 0 || cnKey.length != cnVal.length ) {
//			final String errMsg = "指定の keys,vals には、null、ゼロ件配列、または、個数違いの配列は指定できません。"
		if( cnKey.length != cnVal.length ) {
			final String errMsg = "指定の keys,vals の個数が違ます。"
							+ " keys=" + cnstKeys 
							+ " vals=" + cnstVals ;
			throw new OgRuntimeException( errMsg );
		}

		// 6.9.8.0 (2018/05/28) FindBugs:コンストラクタで初期化されていないフィールドを null チェックなしで null 値を利用している
		// queryType と、nameAry は、setup() メソッドで設定されるため、FindBugs の指摘は、対応済みとなります。
		// とりあえず、条件判定を入れておいて、FindBugs の警告が出ないようにしておきます。
		if( nameAry == null ) {
			// nameAry は、setup() メソッドで設定されるため、このエラーは出ません。
			final String errMsg = "何らかの不測の事態が発生しました。本来、このエラーは出ません。";
			throw new OgRuntimeException( errMsg );
		}

		return new OgBuilder()
			.append( "UPDATE " ).append( table )
			.append( " SET " )
			.appendRoop( 0,clmLen      ,",",i -> nameAry[i] + "=?" )
			.appendRoop( 0,cnVal.length,",",i -> cnKey[i]   + "="  + cnVal[i] )
			.appendNN( " WHERE " , where )		// nullなら、追加しない。where + whereNames
			.toString();
	}

	/**
	 * データを削除する場合に使用するSQL文を作成します。
	 *
	 * DELETE FROM table WHERE where;
	 *
	 * cnstKeys,cnstVal,orderBys は、使いません。
	 * where は、値が存在しない場合は、設定されません。
	 * orderBy は、使いません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @return  削除SQL
	 * @og.rtnNotNull
	 */
	public String getDeleteSQL() {
		if( !"DELETE".equals( queryType ) ) {
			final String errMsg = "指定のQUERYタイプと異なるSQL文を要求しています。" + CR
							+ " 要求SQL=DELETE  queryType=" + queryType ;
			throw new OgRuntimeException( errMsg );
		}

		setup();

		return new OgBuilder()
			.append(   "DELETE FROM " ).append( table )
			.appendNN( " WHERE " , where )		// nullなら、追加しない。where + whereNames
			.toString();
	}

	/**
	 * [カラム名]を含む文字列を分解し、Map に登録します。
	 *
	 * これは、[カラム名]を含む文字列を分解し、カラム名 を取り出し、whrList に
	 * 追加していきます。
	 * 戻り値は、[XXXX] を、? に置換済みの文字列になります。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param	fmt	[カラム名]を含む文字列
	 * @return  PreparedStatementに対応した変換後の文字列
	 */
	private String formatSplit( final String fmt ) {
		if( StringUtil.isNull( fmt ) ) { return fmt; }		// null,ゼロ文字列チェック

		final StringBuilder rtnStr = new StringBuilder( BUFFER_MIDDLE );

		int start = 0;
		int index = fmt.indexOf( '[' );
		while( index >= 0 ) {
			final int end = fmt.indexOf( ']',index );
			if( end < 0 ) {
				final String errMsg = "[ と ] との対応関係がずれています。"
								+ "format=[" + fmt + "] : index=" + index ;
				throw new OgRuntimeException( errMsg );
			}

			// [ より前方の文字列は、rtnStr へ追加する。
			if( index > 0 ) { rtnStr.append( fmt.substring( start,index ) ); }
	//		index == 0 は、][ と連続しているケース

			// [XXXX] の XXXX部分と、位置(?の位置になる)を、Listに登録
			whrList.add( fmt.substring( index+1,end ) );

			rtnStr.append( '?' );		// [XXXX] を、? に置換する。

			start = end+1 ;
			index = fmt.indexOf( '[',start );
		}
		// ] の後方部分は、rtnStr へ追加する。
		rtnStr.append( fmt.substring( start ) );		// '[' が見つからなかった場合は、この処理で、すべての fmt データが、append される。

		return rtnStr.toString();
	}

	/**
	 * QUERYタイプ(SELECT,INSERT,UPDATE,DELETE,MERGE) を指定します。
	 *
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param	queryType	QUERYタイプ
	 */
	public void setQueryType( final String queryType ) {
		if( !StringUtil.isNull( queryType ) ) {
			if( QUERY_TYPE.contains( queryType ) ) {
				this.queryType = queryType;
			}
			else {
				final String errMsg = "queryType は、" + QUERY_TYPE + " から、指定してください。";
				throw new OgRuntimeException( errMsg );
			}
		}
	}

	/**
	 * テーブル名をセットします。
	 *
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param	table	テーブル名
	 */
	public void setTable( final String table ) {
		if( !StringUtil.isNull( table ) ) {
			this.table = table;
		}
	}

	/**
	 * テーブル名を取得します。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @return	テーブル名
	 */
	public String getTable() {
		return table;
	}

	/**
	 * カラム名をセットします。
	 *
	 * カラム名は、登録時に、大文字に変換しておきます。
	 * カラム名は、CSV形式でもかまいません。
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   names  キー（大文字のみ。内部で変換しておきます。）
	 */
	public void setNames( final String names ) {
		if( !StringUtil.isNull( names ) ) {
			this.names = names.toUpperCase(Locale.JAPAN);
		}
	}

	/**
	 * カラム名を取得します。
	 *
	 * 登録時に、すでに、大文字に変換していますので、
	 * ここで取得するカラム名も、大文字に変換されています。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @return	カラム名(大文字に変換済み)
	 */
	public String getNames() {
		return names;
	}

	/**
	 * 除外するカラム名をセットします。
	 *
	 * カラム名は、登録時に、大文字に変換しておきます。
	 * カラム名は、CSV形式でもかまいません。
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   omitNames  キー（大文字のみ。内部で変換しておきます。）
	 */
	public void setOmitNames( final String omitNames ) {
		if( !StringUtil.isNull( omitNames ) ) {
			this.omitNames = omitNames.toUpperCase(Locale.JAPAN);
		}
	}

	/**
	 * WHERE条件をセットします。
	 *
	 * whereNames属性と同時に使用する場合は、"AND" で、処理します。
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   where  WHERE条件
	 */
	public void setWhere( final String where ) {
		if( !StringUtil.isNull( where ) ) {
			this.where = where;
		}
	}

	/**
	 * WHERE条件となるカラム名をCSV形式でセットします。
	 *
	 * カラム名配列より、WHERE条件を、KEY=[KEY] 文字列で作成します。
	 * where属性と同時に使用する場合は、"AND" で、処理します。
	 * 引数が nullか、ゼロ件配列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param	whNames WHERE句作成のためのカラム名
	 */
	public void setWhereNames( final String whNames ) {
		if( !StringUtil.isNull( whNames ) ) {
			final String[] whAry = StringUtil.csv2Array( whNames );

			final StringJoiner sj = new StringJoiner( " AND " );		// 区切り文字
			for( final String whName : whAry ) {
				whrList.add( whName );
				sj.add( whName + "=?" );
			}
			whrNames = sj.toString();
		}
	}

	/**
	 * orderBy条件をセットします。
	 *
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   orderBy  orderBy条件
	 */
	public void setOrderBy( final String orderBy ) {
		if( !StringUtil.isNull( orderBy ) ) {
			this.orderBy = orderBy;
		}
	}

	/**
	 * 固定値のカラム名をセットします。
	 *
	 * nullでなく、ゼロ文字列でない場合のみセットします。
	 * カラム名は、CSV形式でもかまいません。
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   keys  固定値のカラム名
	 */
	public void setConstKeys( final String keys ) {
		if( !StringUtil.isNull( keys ) ) {
			this.cnstKeys = keys;
		}
	}

	/**
	 * 固定値のカラム名に対応した、固定値文字列をセットします。
	 *
	 * nullでなく、ゼロ文字列でない場合のみセットします。
	 * 固定値は、CSV形式でもかまいません。
	 * 引数が nullか、ゼロ文字列の場合は、登録しません。
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 *
	 * @param   vals  固定値
	 */
	public void setConstVals( final String vals ) {
		if( !StringUtil.isNull( vals ) ) {
			this.cnstVals = vals;
		}
	}

	/**
	 * PreparedStatement で、パラメータとなるカラム名の配列を返します。
	 *
	 * これは、QUERYの変数部分 "[カラム名]" を、"?" に置き換えており、
	 * この、カラム名の現れた順番に、配列として返します。
	 * ﾃﾞｰﾀﾍﾞｰｽ処理では、パラメータを設定する場合に、このカラム名を取得し、
	 * オリジナル（SELECT）のカラム番号から、その値を取得しなければなりません。
	 *
	 * カラム名配列は、QUERYタイプ(queryType)に応じて作成されます。
	 * SELECT : パラメータ は使わないので、長さゼロの配列
	 * INSERT : where条件は使わず、names部分のみなので、0 ～ clmLen までの配列
	 * UPDATE : names も、where条件も使うため、すべての配列
	 * DELETE : names条件は使わず、where部分のみなので、clmLen ～ clmLen＋whrLen までの配列(clmLen以降の配列)
	 *
	 * @og.rev 6.8.6.0 (2018/01/19) 新規作成
	 * @og.rev 6.9.8.0 (2018/05/28) セットアップチェックが漏れていた。
	 *
	 * @param	useInsert	queryType="MERGE" の場合に、false:UPDATE , true:INSERT のパラメータのカラム名配列を返します。
	 * @return	パラメータとなるカラム名の配列
	 * @og.rtnNotNull
	 */
	public String[] getParamNames( final boolean useInsert ) {
		// 6.9.8.0 (2018/05/28) FindBugs:コンストラクタで初期化されていないフィールドを null チェックなしで null 値を利用している
		// queryType と、nameAry は、setup() メソッドで設定されるため、FindBugs の指摘は、対応済みとなります。
		// とりあえず、条件判定を入れておいて、FindBugs の警告が出ないようにしておきます。

		// 6.9.8.0 (2018/05/28) セットアップチェックが漏れていた。
		if( !isSetup || StringUtil.isNull( queryType ) || nameAry == null ) {
			final String errMsg = "getParamNames(boolean) は、SQL文を取得してから、行ってください。";
			throw new OgRuntimeException( errMsg );
		}

		final String[] whrAry = whrList.toArray( new String[whrList.size()] );
		final String[] allAry = Arrays.copyOf( nameAry , nameAry.length + whrList.size() );
		System.arraycopy( whrAry , 0 , allAry , nameAry.length , whrAry.length );		// allAry = nameAry + whrAry の作成

		String[] rtnClms = null;
		switch( queryType ) {
			case "SELECT" : rtnClms = new String[0];	break;		// パラメータはない。
			case "INSERT" : rtnClms = nameAry;			break;		// names指定の分だけ、パラメータセット
			case "UPDATE" : rtnClms = allAry;			break;		// names+whereの分だけ、パラメータセット
			case "DELETE" : rtnClms = whrAry;			break;		// whereの分だけ、パラメータセット
			case "MERGE"  : rtnClms = allAry;			break;		// useInsert=false は、UPDATEと同じ
			default : break;
		}

		if( useInsert && "MERGE".equals( queryType ) ) {
			rtnClms = nameAry;					// MERGEで、useInsert=true は、INSERTと同じ
		}

		return rtnClms;
	}
}
