package charactermanaj.model;

import java.awt.Dimension;
import java.io.File;
import java.io.IOException;
import java.io.Serializable;
import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.logging.Level;
import java.util.logging.Logger;

import charactermanaj.model.io.PartsDataLoader;
import charactermanaj.util.ApplicationLogger;

/**
 * キャラクターデータ
 * @author seraphy
 */
public class CharacterData implements Serializable, PartsSpecResolver {

	/**
	 * シリアライズバージョン 
	 */
	private static final long serialVersionUID = -381763373314240953L;

	/**
	 * ロガー
	 */
	private static final Logger logger = ApplicationLogger.getLogger();
	

	/**
	 * キャラクターデータを表示名順にソートするための比較器.<br>
	 */
	public static final Comparator<CharacterData> SORT_DISPLAYNAME = new Comparator<CharacterData>() {
		public int compare(CharacterData o1, CharacterData o2) {
			if (!o1.isValid() || !o2.isValid()) {
				return o1.isValid() == o2.isValid() ? 0 : o1.isValid() ? 1 : -1;
			}
			int ret = o1.getName().compareTo(o2.getName());
			if (ret == 0) {
				ret = o1.getId().compareTo(o2.getId());
			}
			if (ret == 0) {
				ret = o1.getDocBase().toString().compareTo(o2.getDocBase().toString());
			}
			return ret;
		}
	};

	
	/**
	 * キャラクターデータを定義しているXML.<br>
	 * docBase自身はxml定義には含まれず、xmlをロードした位置を記憶するためにPersistentクラスによって設定される.<br>
	 */
	private URL docBase;

	
	
	/**
	 * キャラクターデータの内部用ID.<br>
	 * キャラクターデータの構造を判定するために用いる.<br>
	 */
	private String id;
	
	/**
	 * キャラクターデータの更新番号.<br>
	 * キャラクターデータの構造が変更されたことを識別するために用いる.<br>
	 */
	private String rev;
	
	/**
	 * 表示用名
	 */
	private String localizedName;

	/**
	 * 作成者
	 */
	private String author;
	
	/**
	 * 説明
	 */
	private String description;

	/**
	 * イメージサイズ
	 */
	private Dimension imageSize;
	
	/**
	 * カテゴリ(定義順)
	 */
	private OrderedMap<String, PartsCategory> partsCategories = OrderedMap.emptyMap();

	/**
	 * 雑多なプロパティ.<br>
	 */
	private Properties properties = new Properties();
	
	
	/**
	 * プリセットのマップ.<br>
	 * キーはプリセット自身のID、値はプリセット自身.<br>
	 */
	private HashMap<String, PartsSet> presets = new HashMap<String, PartsSet>();
	
	/**
	 * デフォルトのプリセットのID
	 */
	private String defaultPartsSetId;

	/**
	 * カラーグループの定義.<br>
	 */
	private OrderedMap<String, ColorGroup> colorGroups = OrderedMap.emptyMap();
	
	
	
	/**
	 * パーツカラーマネージャ.<br>
	 * (非シリアライズデータ、デシリアライズ時には新規インスタンスが作成される).<br>
	 */
	private transient PartsColorManager partsColorMrg = new PartsColorManager(this);
	
	/**
	 * パーツデータローダー.<br>
	 * パーツをロードしたときに設定され、リロードするときに使用する.<br>
	 * パーツを一度もロードしていない場合はnull.
	 * (非シリアライズデータ、デシリアライズ時はnullのまま).<br>
	 */
	private transient PartsDataLoader partsDataLoader;
	
	/**
	 * パーツイメージのセット.<br>
	 * (キャラクターセットはパーツイメージをもったままシリアライズされることは想定していないが、可能ではある。)
	 */
	private Map<PartsCategory, Map<PartsIdentifier, PartsSpec>> images
		= new HashMap<PartsCategory, Map<PartsIdentifier, PartsSpec>>();

	
	
	/**
	 * シリアライズする
	 * @param stream 出力先
	 * @throws IOException 失敗
	 */
	private void writeObject(java.io.ObjectOutputStream stream) throws IOException {
		 stream.defaultWriteObject();
	}

	/**
	 * 基本情報のみをコピーして返します.<br>
	 * DocBase, ID, REV, Name, Author, Description, ImageSize、および, PartsCategory, ColorGroup, PartSetのコレクションがコピーされます.<br>
	 * それ以外のものはコピーされません.<br>
	 * @param generateNewId 新しいIDを生成する場合。IDとREVが新たに生成され、DocBaseはnullが設定されます.
	 * @return 基本情報をコピーした新しいインスタンス
	 */
	public CharacterData duplicateBasicInfo(boolean generateNewId) {
		CharacterData cd = new CharacterData();

		if (!generateNewId) {
			cd.setId(this.id);
			cd.setRev(this.rev);
			cd.setDocBase(this.docBase);
		} else {
			String uuid = UUID.randomUUID().toString();
			cd.setId("cd" + uuid);
			cd.setRev("rev" + uuid);
			cd.setDocBase(null);
		}
		cd.setName(this.localizedName);

		cd.setAuthor(this.author);
		cd.setDescription(this.description);
		
		cd.setImageSize(this.getImageSize());
		
		ArrayList<PartsCategory> partsCategories = new ArrayList<PartsCategory>();
		partsCategories.addAll(this.getPartsCategories());
		cd.setPartsCategories(partsCategories.toArray(new PartsCategory[partsCategories.size()]));

		ArrayList<ColorGroup> colorGroups = new ArrayList<ColorGroup>();
		colorGroups.addAll(this.getColorGroups());
		cd.setColorGroups(colorGroups);

		for (PartsSet partsSet : this.getPartsSets().values()) {
			cd.addPartsSet(partsSet.clone());
		}
		cd.setDefaultPartsSetId(this.defaultPartsSetId);
		
		return cd;
	}
	
	/**
	 * キャラクターデータが同じ構造であるか?
	 * ID、サイズ、カラーグループ、カテゴリ、レイヤーの各情報が等しければtrue、それ以外はfalse.<br>
	 * 上記以外の項目(コメントや作者、プリセット等)については判定しない.<br>
	 * カラーグループの表示名や順序、カテゴリの順序や表示名、複数アイテム可などの違いは構造の変更とみなさない.<br>
	 * レイヤーはレイヤーID、重ね合わせ順、対象ディレクトリの3点が変更されている場合は構造の変更とみなす.<br>
	 * いずれも個数そのものが変わっている場合は変更とみなす.<br>
	 * 自分または相手がValidでなければ常にfalseを返す.<br>
	 * @param other 比較対象
	 * @return 同じ構造であればtrue、そうでなければfalse
	 */
	public boolean isSameStructure(CharacterData other) {
		if (other == null) {
			throw new IllegalArgumentException();
		}
		if (!this.isValid() || !other.isValid()) {
			// 自分または相手がinvalidであれば構造的には常に不一致と見なす.
			return false;
		}

		// IDが等しいか?
		if (id == null) {
			if (other.id != null) {
				return false;
			}
		} else if (!id.equals(other.id)) {
			return false;
		}
		
		// サイズが等しいか?
		if (imageSize == null) {
			if (other.images != null) {
				return false;
			}
		} else if (other.images == null || !imageSize.equals(other.imageSize)) {
			return false;
		}
		
		// カラーグループが等しいか? (順序は問わない)
		// IDのみによって判定する
		ArrayList<ColorGroup> colorGroup1 = new ArrayList<ColorGroup>(getColorGroups());
		ArrayList<ColorGroup> colorGroup2 = new ArrayList<ColorGroup>(other.getColorGroups());
		if (colorGroup1.size() != colorGroup2.size()) {
			return false;
		}
		if (!colorGroup1.containsAll(colorGroup2)) {
			return false;
		}
		
		// カテゴリが等しいか? (順序は問わない)
		// IDによってのみ判定する.
		ArrayList<PartsCategory> categories1 = new ArrayList<PartsCategory>(getPartsCategories());
		ArrayList<PartsCategory> categories2 = new ArrayList<PartsCategory>(other.getPartsCategories()); 
		Comparator<PartsCategory> sortCategoryId = new Comparator<PartsCategory>() {
			public int compare(PartsCategory o1, PartsCategory o2) {
				int ret = o1.getCategoryId().compareTo(o2.getCategoryId());
				if (ret == 0) {
					ret = o1.getOrder() - o2.getOrder();
				}
				return ret;
			}
		};
		// カテゴリID順に並び替えて比較する.
		Collections.sort(categories1, sortCategoryId);
		Collections.sort(categories2, sortCategoryId);
		if (!categories1.equals(categories2)) {
			return false;
		}
		
		// レイヤーが等しいか?
		// ID、重ね順序、dirによってのみ判定する.
		int mx = categories1.size();
		for (int idx = 0; idx < mx; idx++) {
			PartsCategory category1 = categories1.get(idx);
			PartsCategory category2 = categories2.get(idx);
			
			ArrayList<Layer> layers1 = new ArrayList<Layer>(category1.getLayers());
			ArrayList<Layer> layers2 = new ArrayList<Layer>(category2.getLayers());
			
			Comparator<Layer> sortLayerId = new Comparator<Layer>() {
				public int compare(Layer o1, Layer o2) {
					int ret = o1.getId().compareTo(o2.getId());
					if (ret == 0) {
						ret = o1.getOrder() - o2.getOrder();
					}
					return ret;
				}
			};
			
			Collections.sort(layers1, sortLayerId);
			Collections.sort(layers2, sortLayerId);

			if (!layers1.equals(layers2)) {
				return false;
			}
		}
		
		return true;
	}
	
	/**
	 * デシリアライズする.
	 * @param stream 入力もと
	 * @throws IOException 失敗
	 * @throws ClassNotFoundException 失敗
	 */
	private void readObject(java.io.ObjectInputStream stream) throws IOException, ClassNotFoundException {
		 stream.defaultReadObject();
		 partsColorMrg = new PartsColorManager(this);
	}

	
	public void setAuthor(String author) {
		this.author = author;
	}
	
	/**
	 * 説明を設定する.<br>
	 * 説明の改行コードはプラットフォーム固有の改行コードに変換される.<br>
	 * @param description
	 */
	public void setDescription(String description) {
		if (description != null) {
			description = description.replace("\r\n", "\n");
			description = description.replace("\r", "\n");
			description = description.replace("\n", System.getProperty("line.separator"));
		}
		this.description = description;
	}
	
	public String getAuthor() {
		return author;
	}
	
	/**
	 * 説明を取得する.<br>
	 * 説明の改行コードはプラットフォーム固有の改行コードとなる.<br>
	 * @return 説明
	 */
	public String getDescription() {
		return description;
	}
	
	public String getId() {
		return id;
	}
	
	public void setId(String id) {
		this.id = id;
	}
	
	public String getRev() {
		return rev;
	}
	
	public void setRev(String rev) {
		this.rev = rev;
	}
	
	public void setDocBase(URL docBase) {
		this.docBase = docBase;
	}
	
	public URL getDocBase() {
		return docBase;
	}

	/**
	 * ディレクトリを監視するか? (デフォルトは監視する)
	 * @return ディレクトリを監視する場合はtrue
	 */
	public boolean isWatchDirectory() {
		try {
			String value = properties.getProperty("watch-dir");
			if (value != null) {
				return Boolean.parseBoolean(value);
			}
		} catch (RuntimeException ex) {
			logger.log(Level.WARNING, "watch-dir property is invalid.", ex);
		}
		// デフォルトは監視する.
		return true;
	}
	
	/**
	 * ディレクトリを監視するか指定する.
	 * @param watchDir 監視する場合はtrue、しない場合はfalse
	 */
	public void setWatchDirectory(boolean watchDir) {
		properties.setProperty("watch-dir", Boolean.toString(watchDir));
	}
	
	public String getProperty(String key) {
		if (key == null || key.trim().length() == 0) {
			throw new IllegalArgumentException();
		}
		return properties.getProperty(key.trim());
	}
	
	public void setProperty(String key, String value) {
		if (key == null || key.trim().length() == 0) {
			throw new IllegalArgumentException();
		}
		properties.setProperty(key.trim(), value);
	}
	
	public Collection<String> getPropertyNames() {
		ArrayList<String> names = new ArrayList<String>();
		for (Object key : properties.keySet()) {
			names.add(key.toString());
		}
		return names;
	}
	
	/**
	 * 有効なキャラクターデータであるか?
	 * ID, Name, DocBaseが存在するものが有効なキャラクターデータである.<br>
	 * @return 有効であればtrue
	 */
	public boolean isValid() {
		return id != null && id.length() > 0 && localizedName != null
				&& localizedName.length() > 0 && docBase != null;
	}
	
	/**
	 * 編集可能か?<br>
	 * まだdocbaseが指定されていない新しいインスタンスであるか、
	 * もしくはdocbaseが実在しファイルであり且つ読み込み可能であるか、
	 * もしくはdocbaseがまだ存在しない場合は、その親ディレクトリが読み書き可能であるか?
	 * @return 編集可能であればtrue
	 */
	public boolean canWrite() {
		if (docBase == null) {
			return true;
		}

		// ファィルでありファイルまたは親ディレクトリが書き込み可能であれば編集可
		// そうでなければ編集不可
		if (docBase.getProtocol().equals("file")) {
			File xmlFile = new File(docBase.getPath());
			if (!xmlFile.exists()) {
				// まだ存在しなければ、親ディレクトリが読み書き可能であるか?
				File parent = xmlFile.getParentFile();
				if (parent.exists() && parent.canWrite() && parent.canRead()) {
					return true;
				}
			} else if (xmlFile.canWrite() && xmlFile.canRead()) {
				// 読み書き可能?
				return true;
			}
		}
		return false;
	}

	/**
	 * このプロファイルはデフォルトのプロファイルIDと等しいか?<br>
	 * プロファイルIDは重複可能なので、デフォルトのプロファイルIDをもつキャラクターデータが1つであるとは限らないことに注意.<br>
	 * @return デフォルトのプロファイルIDに等しいキャラクターデータであればtrue
	 */
	public boolean isDefaultProfile() {
		if (!isValid()) {
			return false;
		}
		AppConfig appConfig = AppConfig.getInstance();
		String defaultProfileId = appConfig.getDefaultProfileId();
		if (defaultProfileId != null && defaultProfileId.equals(id)) {
			return true;
		}
		return false;
	}
	
	/**
	 * キャラクター名を設定する.
	 * @param name
	 */
	public void setName(String name) {
		this.localizedName = name;
	}
	
	/**
	 * キャラクター名を取得する.
	 * @return
	 */
	public String getName() {
		return localizedName;
	}
	
	public void setImageSize(Dimension imageSize) {
		if (imageSize != null) {
			imageSize = (Dimension) imageSize.clone();
		}
		this.imageSize = imageSize;
	}
	
	public Dimension getImageSize() {
		return imageSize != null ? (Dimension) imageSize.clone() : null;
	}
	
	public void setColorGroups(Collection<ColorGroup> colorGroups) {
		if (colorGroups == null) {
			throw new IllegalArgumentException();
		}
		
		ArrayList<ColorGroup> colorGroupWithNA = new ArrayList<ColorGroup>();

		colorGroupWithNA.add(ColorGroup.NA);
		for (ColorGroup colorGroup : colorGroups) {
			if (colorGroup.isEnabled()) {
				colorGroupWithNA.add(colorGroup);
			}
		}
		
		OrderedMap<String, ColorGroup> ret = new OrderedMap<String, ColorGroup>(
				colorGroupWithNA,
				new OrderedMap.KeyDetector<String, ColorGroup>() {
					public String getKey(ColorGroup data) {
						return data.getId();
					}
				});
		this.colorGroups = ret;
	}
	
	/**
	 * カラーグループIDからカラーグループを取得する.<br>
	 * 存在しない場合はN/Aを返す.<br>
	 * @param colorGroupId カラーグループID
	 * @return カラーグループ
	 */
	public ColorGroup getColorGroup(String colorGroupId) {
		ColorGroup cg = colorGroups.get(colorGroupId);
		if (cg != null) {
			return cg;
		}
		return ColorGroup.NA;
	}
	
	public Collection<ColorGroup> getColorGroups() {
		return colorGroups.values();
	}
	
	public PartsCategory getPartsCategory(String categoryId) {
		if (partsCategories == null) {
			return null;
		}
		return partsCategories.get(categoryId);
	}
	
	public void setPartsCategories(PartsCategory[] partsCategories) {
		if (partsCategories == null) {
			partsCategories = new PartsCategory[0];
		}
		this.partsCategories = new OrderedMap<String, PartsCategory>(
				Arrays.asList(partsCategories),
				new OrderedMap.KeyDetector<String, PartsCategory>() {
					public String getKey(PartsCategory data) {
						return data.getCategoryId();
					}
				});
	}
	
	public List<PartsCategory> getPartsCategories() {
		return partsCategories.asList();
	}
	
	/**
	 * パーツデータをロードする.<br>
	 * @param partsDataLoader ローダー
	 */
	public void loadPartsData(PartsDataLoader partsDataLoader) {
		if (partsDataLoader == null) {
			throw new IllegalArgumentException();
		}
		this.partsDataLoader = partsDataLoader;
		reloadPartsData();
	}
	
	/**
	 * パーツデータをリロードする.<br>
	 * ロード時に使用したローダーを使ってパーツを再ロードします.<br>
	 * まだ一度もロードしていない場合はIllegalStateException例外が発生します.<br>
	 * @return 変更があった場合はtrue、ない場合はfalse
	 */
	public boolean reloadPartsData() {
		if (partsDataLoader == null) {
			throw new IllegalStateException("partsDataLoader is not set.");
		}
		// パーツデータのロード
		images.clear();
		for (PartsCategory category : partsCategories.asList()) {
			images.put(category, partsDataLoader.load(category));
		}
		// NOTE: とりあえずパーツの変更を検査せず、常に変更ありにしておく。とりあえず実害ない。
		return true;
	}
	
	/**
	 * {@inheritDoc}
	 */
	public PartsSpec getPartsSpec(PartsIdentifier partsIdentifier) {
		if (partsIdentifier == null) {
			throw new IllegalArgumentException();
		}
		PartsCategory partsCategory = partsIdentifier.getPartsCategory();
		Map<PartsIdentifier, PartsSpec> partsSpecMap = images.get(partsCategory);
		if (partsSpecMap != null) {
			PartsSpec partsSpec = partsSpecMap.get(partsIdentifier);
			if (partsSpec != null) {
				return partsSpec;
			}
		}
		return null;
	}
	
	/**
	 * {@inheritDoc}
	 */
	public Map<PartsIdentifier, PartsSpec> getPartsSpecMap(PartsCategory category) {
		Map<PartsIdentifier, PartsSpec> partsImageMap = images.get(category);
		if (partsImageMap == null) {
			return Collections.emptyMap();
		}
		return partsImageMap;
	}

	public PartsColorManager getPartsColorManager() {
		return this.partsColorMrg;
	}
	
	/**
	 * パーツセットを登録します.<br>
	 * お気に入りとプリセットの両方の共用です.<br>
	 * IDおよび名前がないものは登録されず、falseを返します.<br>
	 * パーツセットは、このキャラクター定義に定義されているカテゴリに正規化されます.<br>
	 * 正規化された結果カテゴリが一つもなくなった場合は何も登録されず、falseを返します.<br>
	 * 登録された場合はtrueを返します.<br>
	 * @param partsSet
	 * @return
	 */
	public boolean addPartsSet(PartsSet partsSet) {
		if (partsSet == null) {
			throw new IllegalArgumentException();
		}
		if (partsSet.getPartsSetId() == null
				|| partsSet.getPartsSetId().length() == 0
				|| partsSet.getLocalizedName() == null
				|| partsSet.getLocalizedName().length() == 0) {
			return false;
		}
		PartsSet compatiblePartsSet = partsSet.createCompatible(this);
		if (compatiblePartsSet.isEmpty()) {
			return false;
		}
		presets.put(compatiblePartsSet.getPartsSetId(), compatiblePartsSet);
		return true;
	}
	
	/**
	 * プリセットパーツおよびパーツセット(Favorites)のコレクション.
	 * @return パーツセットのコレクション
	 */
	public Map<String, PartsSet> getPartsSets() {
		return presets;
	}

	/**
	 * プリセットパーツおよびパーツセットをリセットします.<br>
	 * @param noRemovePreset プリセットは削除せず残し、プリセット以外のパーツセットをクリアする場合はtrue、falseの場合は全て削除される.
	 */
	public void clearPartsSets(boolean noRemovePreset) {
		if (!noRemovePreset) {
			// 全部消す
			presets.clear();
			defaultPartsSetId = null;
		} else {
			// プリセット以外を消す
			Iterator<Map.Entry<String, PartsSet>> ite = presets.entrySet().iterator();
			while (ite.hasNext()) {
				Map.Entry<String, PartsSet> entry = ite.next();
				if (!entry.getValue().isPresetParts()) {
					// デフォルトパーツセットであれば、デフォルトパーツセットもnullにする.
					// (ただし、デフォルトパーツセットはプリセットであることを想定しているので、この処理は安全策用。)
					if (entry.getKey().equals(defaultPartsSetId)) {
						defaultPartsSetId = null;
					}
					ite.remove();
				}
			}
		}
	}
	
	/**
	 * デフォルトのパーツセットを取得する.<br>
	 * そのパーツセットIDが実在するか、あるいは、それがプリセットであるか、などは一切関知しない.<br>
	 * 呼び出しもとで必要に応じてチェックすること.<br>
	 * @return デフォルトとして指定されているパーツセットのID、なければnull
	 */
	public String getDefaultPartsSetId() {
		return defaultPartsSetId;
	}
	
	/**
	 * デフォルトのパーツセットIDを指定する.<br>
	 * nullの場合はデフォルトのパーツセットがないことを示す.<br>
	 * パーツセットはプリセットであることが想定されるが、<br>
	 * 実際に、その名前のパーツセットが存在するか、あるいは、そのパーツセットがプリセットであるか、などの判定は一切行わない.<br>
	 * @param defaultPartsSetId パーツセットID、もしくはnull
	 */
	public void setDefaultPartsSetId(String defaultPartsSetId) {
		this.defaultPartsSetId = defaultPartsSetId;
	}
	
	@Override
	public String toString() {
		StringBuilder buf = new StringBuilder();
		buf.append("character-id: " + id);
		buf.append("/rev:" + rev);
		buf.append("/name:" + localizedName);
		buf.append("/image-size:" + imageSize.width + "x" + imageSize.height);
		buf.append("/docBase:" + docBase);
		return buf.toString();
	}
	
}
