// ***************************************************************************************************************************
// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file *
// * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file        *
// * to you 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.apache.juneau.jsonschema;

import static org.apache.juneau.internal.ObjectUtils.*;
import static org.apache.juneau.jsonschema.TypeCategory.*;

import java.lang.reflect.*;
import java.util.*;
import java.util.regex.*;

import org.apache.juneau.*;
import org.apache.juneau.json.*;
import org.apache.juneau.jsonschema.annotation.*;
import org.apache.juneau.parser.ParseException;
import org.apache.juneau.serializer.*;
import org.apache.juneau.transform.*;

/**
 * Session object that lives for the duration of a single use of {@link JsonSchemaSerializer}.
 *
 * <p>
 * This class is NOT thread safe.
 * It is typically discarded after one-time use although it can be reused within the same thread.
 */
public class JsonSchemaGeneratorSession extends BeanTraverseSession {

	private final JsonSchemaGenerator ctx;
	private final Map<String,ObjectMap> defs;
	private JsonSerializerSession jsSession;

	/**
	 * Create a new session using properties specified in the context.
	 *
	 * @param ctx
	 * 	The context creating this session object.
	 * 	The context contains all the configuration settings for this object.
	 * @param args
	 * 	Runtime arguments.
	 * 	These specify session-level information such as locale and URI context.
	 * 	It also include session-level properties that override the properties defined on the bean and
	 * 	serializer contexts.
	 */
	protected JsonSchemaGeneratorSession(JsonSchemaGenerator ctx, BeanSessionArgs args) {
		super(ctx, args);
		this.ctx = ctx;
		if (isUseBeanDefs())
			defs = new TreeMap<>();
		else
			defs = null;
	}

	/**
	 * Returns the JSON-schema for the specified object.
	 *
	 * @param o
	 * 	The object.
	 * 	<br>Can either be a POJO or a <c>Class</c>/<c>Type</c>.
	 * @return The schema for the type.
	 * @throws BeanRecursionException Bean recursion occurred.
	 * @throws SerializeException Error occurred.
	 */
	public ObjectMap getSchema(Object o) throws BeanRecursionException, SerializeException {
		return getSchema(toClassMeta(o), "root", null, false, false, null);
	}

	/**
	 * Returns the JSON-schema for the specified type.
	 *
	 * @param type The object type.
	 * @return The schema for the type.
	 * @throws BeanRecursionException Bean recursion occurred.
	 * @throws SerializeException Error occurred.
	 */
	public ObjectMap getSchema(Type type) throws BeanRecursionException, SerializeException {
		return getSchema(getClassMeta(type), "root", null, false, false, null);
	}

	/**
	 * Returns the JSON-schema for the specified type.
	 *
	 * @param cm The object type.
	 * @return The schema for the type.
	 * @throws BeanRecursionException Bean recursion occurred.
	 * @throws SerializeException Error occurred.
	 */
	public ObjectMap getSchema(ClassMeta<?> cm) throws BeanRecursionException, SerializeException {
		return getSchema(cm, "root", null, false, false, null);
	}

	@SuppressWarnings({ "unchecked", "rawtypes" })
	private ObjectMap getSchema(ClassMeta<?> eType, String attrName, String[] pNames, boolean exampleAdded, boolean descriptionAdded, JsonSchemaBeanPropertyMeta jsbpm) throws BeanRecursionException, SerializeException {

		if (ctx.isIgnoredType(eType))
			return null;

		ObjectMap out = new ObjectMap();

		if (eType == null)
			eType = object();

		ClassMeta<?> aType;			// The actual type (will be null if recursion occurs)
		ClassMeta<?> sType;			// The serialized type
		PojoSwap pojoSwap = eType.getPojoSwap(this);

		aType = push(attrName, eType, null);

		sType = eType.getSerializedClassMeta(this);

		String type = null, format = null;
		Object example = null, description = null;

		boolean useDef = isUseBeanDefs() && sType.isBean() && pNames == null;

		if (useDef) {
			exampleAdded = false;
			descriptionAdded = false;
		}

		if (useDef && defs.containsKey(getBeanDefId(sType))) {
			pop();
			return new ObjectMap().append("$ref", getBeanDefUri(sType));
		}

		ObjectMap ds = getDefaultSchemas().get(sType.getInnerClass().getName());
		if (ds != null && ds.containsKey("type")) {
			pop();
			return out.appendAll(ds);
		}

		JsonSchemaClassMeta jscm = null;
		ClassMeta pojoSwapCM = pojoSwap == null ? null : getClassMeta(pojoSwap.getClass());
		if (pojoSwapCM != null && pojoSwapCM.getAnnotation(Schema.class) != null)
			jscm = getJsonSchemaClassMeta(pojoSwapCM);
		if (jscm == null)
			jscm = getJsonSchemaClassMeta(sType);

		TypeCategory tc = null;

		if (sType.isNumber()) {
			tc = NUMBER;
			if (sType.isDecimal()) {
				type = "number";
				if (sType.isFloat()) {
					format = "float";
				} else if (sType.isDouble()) {
					format = "double";
				}
			} else {
				type = "integer";
				if (sType.isShort()) {
					format = "int16";
				} else if (sType.isInteger()) {
					format = "int32";
				} else if (sType.isLong()) {
					format = "int64";
				}
			}
		} else if (sType.isBoolean()) {
			tc = BOOLEAN;
			type = "boolean";
		} else if (sType.isMap()) {
			tc = MAP;
			type = "object";
		} else if (sType.isBean()) {
			tc = BEAN;
			type = "object";
		} else if (sType.isCollection()) {
			tc = COLLECTION;
			type = "array";
		} else if (sType.isArray()) {
			tc = ARRAY;
			type = "array";
		} else if (sType.isEnum()) {
			tc = ENUM;
			type = "string";
		} else if (sType.isCharSequence() || sType.isChar()) {
			tc = STRING;
			type = "string";
		} else if (sType.isUri()) {
			tc = STRING;
			type = "string";
			format = "uri";
		} else {
			tc = STRING;
			type = "string";
		}

		// Add info from @Schema on bean property.
		if (jsbpm != null) {
			out.appendAll(jsbpm.getSchema());
		}

		out.appendAll(jscm.getSchema());

		out.appendIf(false, true, true, "type", type);
		out.appendIf(false, true, true, "format", format);

		if (aType != null) {

			example = getExample(sType, tc, exampleAdded);
			description = getDescription(sType, tc, descriptionAdded);
			exampleAdded |= example != null;
			descriptionAdded |= description != null;

			if (tc == BEAN) {
				ObjectMap properties = new ObjectMap();
				BeanMeta bm = getBeanMeta(sType.getInnerClass());
				if (pNames != null)
					bm = new BeanMetaFiltered(bm, pNames);
				for (Iterator<BeanPropertyMeta> i = bm.getPropertyMetas().iterator(); i.hasNext();) {
					BeanPropertyMeta p = i.next();
					if (p.canRead())
						properties.put(p.getName(), getSchema(p.getClassMeta(), p.getName(), p.getProperties(), exampleAdded, descriptionAdded, getJsonSchemaBeanPropertyMeta(p)));
				}
				out.put("properties", properties);

			} else if (tc == COLLECTION) {
				ClassMeta et = sType.getElementType();
				if (sType.isCollection() && sType.getInfo().isChildOf(Set.class))
					out.put("uniqueItems", true);
				out.put("items", getSchema(et, "items", pNames, exampleAdded, descriptionAdded, null));

			} else if (tc == ARRAY) {
				ClassMeta et = sType.getElementType();
				if (sType.isCollection() && sType.getInfo().isChildOf(Set.class))
					out.put("uniqueItems", true);
				out.put("items", getSchema(et, "items", pNames, exampleAdded, descriptionAdded, null));

			} else if (tc == ENUM) {
				out.put("enum", getEnums(sType));

			} else if (tc == MAP) {
				ObjectMap om = getSchema(sType.getValueType(), "additionalProperties", null, exampleAdded, descriptionAdded, null);
				if (! om.isEmpty())
					out.put("additionalProperties", om);

			}
		}

		out.appendAll(jscm.getSchema());

		out.appendIf(false, true, true, "description", description);
		out.appendIf(false, true, true, "x-example", example);

		if (ds != null)
			out.appendAll(ds);

		if (useDef) {
			defs.put(getBeanDefId(sType), out);
			out = new ObjectMap().append("$ref", getBeanDefUri(sType));
		}

		pop();

		return out;
	}

	private List<String> getEnums(ClassMeta<?> cm) {
		List<String> l = new ArrayList<>();
		for (Enum<?> e : getEnumConstants(cm.getInnerClass()))
			l.add(cm.toString(e));
		return l;
	}

	private Object getExample(ClassMeta<?> sType, TypeCategory t, boolean exampleAdded) throws SerializeException {
		boolean canAdd = isAllowNestedExamples() || ! exampleAdded;
		if (canAdd && (getAddExamplesTo().contains(t) || getAddExamplesTo().contains(ANY))) {
			Object example = sType.getExample(this);
			if (example != null) {
				try {
					return JsonParser.DEFAULT.parse(toJson(example), Object.class);
				} catch (ParseException e) {
					throw new SerializeException(e);
				}
			}
		}
		return null;
	}

	private String toJson(Object o) throws SerializeException {
		if (jsSession == null)
			jsSession = ctx.getJsonSerializer().createSession(null);
		return jsSession.serializeToString(o);
	}

	private Object getDescription(ClassMeta<?> sType, TypeCategory t, boolean descriptionAdded) {
		boolean canAdd = isAllowNestedDescriptions() || ! descriptionAdded;
		if (canAdd && (getAddDescriptionsTo().contains(t) || getAddDescriptionsTo().contains(ANY)))
			return sType.toString();
		return null;
	}

	/**
	 * Returns the definition ID for the specified class.
	 *
	 * @param cm The class to get the definition ID of.
	 * @return The definition ID for the specified class.
	 */
	public String getBeanDefId(ClassMeta<?> cm) {
		return getBeanDefMapper().getId(cm);
	}

	/**
	 * Returns the definition URI for the specified class.
	 *
	 * @param cm The class to get the definition URI of.
	 * @return The definition URI for the specified class.
	 */
	public java.net.URI getBeanDefUri(ClassMeta<?> cm) {
		return getBeanDefMapper().getURI(cm);
	}

	/**
	 * Returns the definition URI for the specified class.
	 *
	 * @param id The definition ID to get the definition URI of.
	 * @return The definition URI for the specified class.
	 */
	public java.net.URI getBeanDefUri(String id) {
		return getBeanDefMapper().getURI(id);
	}

	/**
	 * Returns the definitions that were gathered during this session.
	 *
	 * <p>
	 * This map is modifiable and affects the map in the session.
	 *
	 * @return
	 * 	The definitions that were gathered during this session, or <jk>null</jk> if {@link JsonSchemaGenerator#JSONSCHEMA_useBeanDefs} was not enabled.
	 */
	public Map<String,ObjectMap> getBeanDefs() {
		return defs;
	}

	/**
	 * Adds a schema definition to this session.
	 *
	 * @param id The definition ID.
	 * @param def The definition schema.
	 * @return This object (for method chaining).
	 */
	public JsonSchemaGeneratorSession addBeanDef(String id, ObjectMap def) {
		if (defs != null)
			defs.put(id, def);
		return this;
	}

	//-----------------------------------------------------------------------------------------------------------------
	// Properties
	//-----------------------------------------------------------------------------------------------------------------

	/**
	 * Configuration property:  Add descriptions to types.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_addDescriptionsTo
	 * @return
	 * 	Set of categories of types that descriptions should be automatically added to generated schemas.
	 */
	protected final Set<TypeCategory> getAddDescriptionsTo() {
		return ctx.getAddDescriptionsTo();
	}

	/**
	 * Configuration property:  Add examples.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_addExamplesTo
	 * @return
	 * 	Set of categories of types that examples should be automatically added to generated schemas.
	 */
	protected final Set<TypeCategory> getAddExamplesTo() {
		return ctx.getAddExamplesTo();
	}

	/**
	 * Configuration property:  Allow nested descriptions.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_allowNestedDescriptions
	 * @return
	 * 	<jk>true</jk> if nested descriptions are allowed in schema definitions.
	 */
	protected final boolean isAllowNestedDescriptions() {
		return ctx.isAllowNestedDescriptions();
	}

	/**
	 * Configuration property:  Allow nested examples.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_allowNestedExamples
	 * @return
	 * 	<jk>true</jk> if nested examples are allowed in schema definitions.
	 */
	protected final boolean isAllowNestedExamples() {
		return ctx.isAllowNestedExamples();
	}

	/**
	 * Configuration property:  Bean schema definition mapper.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_beanDefMapper
	 * @return
	 * 	Interface to use for converting Bean classes to definition IDs and URIs.
	 */
	protected final BeanDefMapper getBeanDefMapper() {
		return ctx.getBeanDefMapper();
	}

	/**
	 * Configuration property:  Default schemas.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_defaultSchemas
	 * @return
	 * 	Custom schema information for particular class types.
	 */
	protected final Map<String,ObjectMap> getDefaultSchemas() {
		return ctx.getDefaultSchemas();
	}

	/**
	 * Configuration property:  Ignore types from schema definitions.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_ignoreTypes
	 * @return
	 * 	Custom schema information for particular class types.
	 */
	protected final Set<Pattern> getIgnoreTypes() {
		return ctx.getIgnoreTypes();
	}

	/**
	 * Configuration property:  Use bean definitions.
	 *
	 * @see JsonSchemaGenerator#JSONSCHEMA_useBeanDefs
	 * @return
	 * 	<jk>true</jk> if schemas on beans will be serialized with <js>'$ref'</js> tags.
	 */
	protected final boolean isUseBeanDefs() {
		return ctx.isUseBeanDefs();
	}

	//-----------------------------------------------------------------------------------------------------------------
	// Extended metadata
	//-----------------------------------------------------------------------------------------------------------------

	/**
	 * Returns the language-specific metadata on the specified class.
	 *
	 * @param cm The class to return the metadata on.
	 * @return The metadata.
	 */
	public JsonSchemaClassMeta getJsonSchemaClassMeta(ClassMeta<?> cm) {
		return ctx.getJsonSchemaClassMeta(cm);
	}

	/**
	 * Returns the language-specific metadata on the specified bean property.
	 *
	 * @param bpm The bean property to return the metadata on.
	 * @return The metadata.
	 */
	public JsonSchemaBeanPropertyMeta getJsonSchemaBeanPropertyMeta(BeanPropertyMeta bpm) {
		return ctx.getJsonSchemaBeanPropertyMeta(bpm);
	}

	//-----------------------------------------------------------------------------------------------------------------
	// Utility methods
	//-----------------------------------------------------------------------------------------------------------------

	private ClassMeta<?> toClassMeta(Object o) {
		if (o instanceof Type)
			return getClassMeta((Type)o);
		return getClassMetaForObject(o);
	}

	//-----------------------------------------------------------------------------------------------------------------
	// Other methods
	//-----------------------------------------------------------------------------------------------------------------

	@Override /* Session */
	public ObjectMap toMap() {
		return super.toMap()
			.append("JsonSchemaGeneratorSession", new DefaultFilteringObjectMap()
			);
	}
}
