/*******************************************************************************
 * Copyright (c) 2004, 2009 IBM Corporation and others.
 *
 * This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License 2.0
 * which accompanies this distribution, and is available at
 * https://www.eclipse.org/legal/epl-2.0/
 *
 * SPDX-License-Identifier: EPL-2.0
 *
 * Contributors:
 *     IBM Corporation - initial API and implementation
 *******************************************************************************/
package org.eclipse.core.internal.content;

import java.io.*;
import java.util.*;
import org.eclipse.core.internal.runtime.RuntimeLog;
import org.eclipse.core.runtime.*;
import org.eclipse.core.runtime.content.*;
import org.eclipse.core.runtime.preferences.IScopeContext;
import org.eclipse.osgi.util.NLS;
import org.osgi.service.prefs.BackingStoreException;
import org.osgi.service.prefs.Preferences;

public final class ContentType implements IContentType, IContentTypeInfo {

	/* A placeholder for missing/invalid binary/text describers. */
	private class InvalidDescriber implements IContentDescriber, ITextContentDescriber {
		@Override
		public int describe(InputStream contents, IContentDescription description) {
			return INVALID;
		}

		@Override
		public int describe(Reader contents, IContentDescription description) {
			return INVALID;
		}

		@Override
		public QualifiedName[] getSupportedOptions() {
			return new QualifiedName[0];
		}
	}

	final static byte ASSOCIATED_BY_EXTENSION = 2;
	final static byte ASSOCIATED_BY_NAME = 1;
	private static final String DESCRIBER_ELEMENT = "describer"; //$NON-NLS-1$
	private static ArrayList<FileSpec> EMPTY_LIST = new ArrayList<>(0);
	private static final Object INHERITED_DESCRIBER = "INHERITED DESCRIBER"; //$NON-NLS-1$

	private static final Object NO_DESCRIBER = "NO DESCRIBER"; //$NON-NLS-1$
	final static byte NOT_ASSOCIATED = 0;
	public final static String PREF_DEFAULT_CHARSET = "charset"; //$NON-NLS-1$
	public final static String PREF_FILE_EXTENSIONS = "file-extensions"; //$NON-NLS-1$
	public final static String PREF_FILE_NAMES = "file-names"; //$NON-NLS-1$
	/** @since 3.7 */
	public final static String PREF_FILE_PATTERNS = "file-patterns"; //$NON-NLS-1$
	/** @since 3.6 */
	public static final String PREF_USER_DEFINED = "userDefined"; //$NON-NLS-1$
	/** @since 3.6 */
	public static final String PREF_USER_DEFINED__SEPARATOR = ","; //$NON-NLS-1$
	/** @since 3.6 */
	public static final String PREF_USER_DEFINED__NAME = "name"; //$NON-NLS-1$
	/** @since 3.6 */
	public static final String PREF_USER_DEFINED__BASE_TYPE_ID = "baseTypeId"; //$NON-NLS-1$
	final static byte PRIORITY_HIGH = 1;
	final static byte PRIORITY_LOW = -1;
	final static byte PRIORITY_NORMAL = 0;
	final static int SPEC_PRE_DEFINED = IGNORE_PRE_DEFINED;
	final static int SPEC_USER_DEFINED = IGNORE_USER_DEFINED;
	final static byte STATUS_INVALID = 2;
	final static byte STATUS_UNKNOWN = 0;
	final static byte STATUS_VALID = 1;
	static final String EMPTY_STRING = ""; //$NON-NLS-1$
	private String aliasTargetId;
	private String baseTypeId;
	private boolean builtInAssociations = false;
	private ContentTypeCatalog catalog;
	private IConfigurationElement contentTypeElement;
	private DefaultDescription defaultDescription;
	private Map<QualifiedName, String> defaultProperties;
	private Object describer;
	// we need a Cloneable list
	private ArrayList<FileSpec> fileSpecs = EMPTY_LIST;
	String id;
	private ContentTypeManager manager;
	private String name;
	private byte priority;
	private ContentType target;
	private String userCharset;
	private byte validation = STATUS_UNKNOWN;
	private ContentType baseType;
	// -1 means unknown
	private byte depth = -1;

	public static ContentType createContentType(ContentTypeCatalog catalog, String uniqueId, String name, byte priority,
			String[] fileExtensions, String[] fileNames, String[] filePatterns, String baseTypeId, String aliasTargetId,
			Map<QualifiedName, String> defaultProperties, IConfigurationElement contentTypeElement) {
		ContentType contentType = new ContentType(catalog.getManager());
		contentType.catalog = catalog;
		contentType.defaultDescription = new DefaultDescription(contentType);
		contentType.id = uniqueId;
		contentType.name = name;
		contentType.priority = priority;
		if ((fileExtensions != null && fileExtensions.length > 0) || (fileNames != null && fileNames.length > 0)
				|| (filePatterns != null && filePatterns.length > 0)) {
			contentType.builtInAssociations = true;
			contentType.fileSpecs = new ArrayList<>(fileExtensions.length + fileNames.length + filePatterns.length);
			for (String fileName : fileNames)
				contentType.internalAddFileSpec(fileName, FILE_NAME_SPEC | SPEC_PRE_DEFINED);
			for (String fileExtension : fileExtensions)
				contentType.internalAddFileSpec(fileExtension, FILE_EXTENSION_SPEC | SPEC_PRE_DEFINED);
			for (String fileExtension : filePatterns) {
				contentType.internalAddFileSpec(fileExtension, FILE_PATTERN_SPEC | SPEC_PRE_DEFINED);
			}
		}
		contentType.defaultProperties = defaultProperties;
		contentType.contentTypeElement = contentTypeElement;
		contentType.baseTypeId = baseTypeId;
		contentType.aliasTargetId = aliasTargetId;
		return contentType;
	}

	static FileSpec createFileSpec(String fileSpec, int type) {
		return new FileSpec(fileSpec, type);
	}

	static String getPreferenceKey(int flags) {
		if ((flags & FILE_EXTENSION_SPEC) != 0)
			return PREF_FILE_EXTENSIONS;
		if ((flags & FILE_NAME_SPEC) != 0)
			return PREF_FILE_NAMES;
		if ((flags & FILE_PATTERN_SPEC) != 0)
			return PREF_FILE_PATTERNS;
		throw new IllegalArgumentException("Unknown type: " + flags); //$NON-NLS-1$
	}

	private static String getValidationString(byte validation) {
		return validation == STATUS_VALID ? "VALID" : (validation == STATUS_INVALID ? "INVALID" : "UNKNOWN"); //$NON-NLS-1$ //$NON-NLS-2$ //$NON-NLS-3$
	}

	public static void log(String message, Throwable reason) {
		// don't log CoreExceptions again
		IStatus status = new Status(IStatus.ERROR, ContentMessages.OWNER_NAME, 0, message, reason instanceof CoreException ? null : reason);
		RuntimeLog.log(status);
	}

	public ContentType(ContentTypeManager manager) {
		this.manager = manager;
	}

	@Override
	public void addFileSpec(String fileSpec, int type) throws CoreException {
		Assert.isLegal(type == FILE_EXTENSION_SPEC || type == FILE_NAME_SPEC || type == FILE_PATTERN_SPEC,
				"Unknown type: " + type); //$NON-NLS-1$
		String[] userSet;
		synchronized (this) {
			if (!internalAddFileSpec(fileSpec, type | SPEC_USER_DEFINED))
				return;
			userSet = getFileSpecs(type | IGNORE_PRE_DEFINED);
		}
		// persist using preferences
		Preferences contentTypeNode = manager.getPreferences().node(id);
		String newValue = Util.toListString(userSet);
		// we are adding stuff, newValue must be non-null
		Assert.isNotNull(newValue);
		setPreference(contentTypeNode, getPreferenceKey(type), newValue);
		try {
			contentTypeNode.flush();
		} catch (BackingStoreException bse) {
			String message = NLS.bind(ContentMessages.content_errorSavingSettings, id);
			IStatus status = new Status(IStatus.ERROR, ContentMessages.OWNER_NAME, 0, message, bse);
			throw new CoreException(status);
		}
		// notify listeners
		manager.fireContentTypeChangeEvent(this);
	}

	int describe(IContentDescriber selectedDescriber, ILazySource contents, ContentDescription description) throws IOException {
		try {
			return contents.isText() ? ((ITextContentDescriber) selectedDescriber).describe((Reader) contents, description) : selectedDescriber.describe((InputStream) contents, description);
		} catch (RuntimeException re) {
			// describer seems to be buggy. just disable it (logging the reason)
			invalidateDescriber(re);
		} catch (Error e) {
			// describer got some serious problem. disable it (logging the reason) and throw the error again
			invalidateDescriber(e);
			throw e;
		} catch (LowLevelIOException llioe) {
			// throw the actual exception
			throw llioe.getActualException();
		} catch (IOException ioe) {
			// bugs 67841/ 62443  - non-low level IOException should be "ignored"
			if (ContentTypeManager.DEBUGGING) {
				String message = NLS.bind(ContentMessages.content_errorReadingContents, id);
				ContentType.log(message, ioe);
			}
			// we don't know what the describer would say if the exception didn't occur
			return IContentDescriber.INDETERMINATE;
		} finally {
			contents.rewind();
		}
		return IContentDescriber.INVALID;
	}

	@Override
	public boolean equals(Object another) {
		if (another instanceof ContentType)
			return id.equals(((ContentType) another).id);
		if (another instanceof ContentTypeHandler)
			return id.equals(((ContentTypeHandler) another).id);
		return false;
	}

	public String getAliasTargetId() {
		return aliasTargetId;
	}

	@Override
	public IContentType getBaseType() {
		return baseType;
	}

	String getBaseTypeId() {
		return baseTypeId;
	}

	public ContentTypeCatalog getCatalog() {
		return catalog;
	}

	@Override
	public ContentType getContentType() {
		return this;
	}

	@Override
	public String getDefaultCharset() {
		return getDefaultProperty(IContentDescription.CHARSET);
	}

	@Override
	public IContentDescription getDefaultDescription() {
		return defaultDescription;
	}

	/**
	 * Returns the default value for the given property in this content type, or <code>null</code>.
	 */
	@Override
	public String getDefaultProperty(QualifiedName key) {
		String propertyValue = internalGetDefaultProperty(key);
		if ("".equals(propertyValue)) //$NON-NLS-1$
			return null;
		return propertyValue;
	}

	byte getDepth() {
		byte tmpDepth = depth;
		if (tmpDepth >= 0)
			return tmpDepth;
		// depth was never computed - do it now
		if (baseType == null)
			return depth = 0;
		return depth = (byte) (baseType == null ? 0 : (1 + baseType.getDepth()));
	}

	/**
	 * Public for tests only, should not be called by anyone else.
	 */
	public IContentDescriber getDescriber() {
		try {
			// thread safety
			Object tmpDescriber = describer;
			if (tmpDescriber != null) {
				if (INHERITED_DESCRIBER == tmpDescriber)
					return baseType.getDescriber();
				return (NO_DESCRIBER == tmpDescriber) ? null : (IContentDescriber) tmpDescriber;
			}
			final String describerValue = contentTypeElement != null
					? contentTypeElement.getAttribute(DESCRIBER_ELEMENT)
					: null;
			IConfigurationElement[] childrenDescribers = contentTypeElement != null
					? contentTypeElement.getChildren(DESCRIBER_ELEMENT)
					: new IConfigurationElement[0];
			if (describerValue != null || childrenDescribers.length > 0)
				try {
					if ("".equals(describerValue)) { //$NON-NLS-1$
						describer = NO_DESCRIBER;
						return null;
					}
					describer = tmpDescriber = contentTypeElement.createExecutableExtension(DESCRIBER_ELEMENT);
					return (IContentDescriber) tmpDescriber;
				} catch (CoreException ce) {
					// the content type definition was invalid. Ensure we don't
					// try again, and this content type does not accept any
					// contents
					return invalidateDescriber(ce);
				}
		} catch (InvalidRegistryObjectException e) {
			/*
			 * This should only happen if  an API call is made after the registry has changed and before
			 * the corresponding registry change event has been broadcast.
			 */
			// the configuration element is stale - need to rebuild the catalog
			manager.invalidate();
			// bad timing - next time the client asks for a describer, s/he will have better luck
			return null;
		}
		if (baseType == null) {
			describer = NO_DESCRIBER;
			return null;
		}
		// remember so we don't have to come all the way down here next time
		describer = INHERITED_DESCRIBER;
		return baseType.getDescriber();
	}

	@Override
	public IContentDescription getDescriptionFor(InputStream contents, QualifiedName[] options) throws IOException {
		return internalGetDescriptionFor(ContentTypeManager.readBuffer(contents), options);
	}

	@Override
	public IContentDescription getDescriptionFor(Reader contents, QualifiedName[] options) throws IOException {
		return internalGetDescriptionFor(ContentTypeManager.readBuffer(contents), options);
	}

	@Override
	public String[] getFileSpecs(int typeMask) {
		if (fileSpecs.isEmpty())
			return new String[0];
		// invert the last two bits so it is easier to compare
		typeMask ^= (IGNORE_PRE_DEFINED | IGNORE_USER_DEFINED);
		List<String> result = new ArrayList<>(fileSpecs.size());
		for (FileSpec spec : fileSpecs) {
			if ((spec.getType() & typeMask) == spec.getType())
				result.add(spec.getText());
		}
		return result.toArray(new String[result.size()]);
	}

	@Override
	public String getId() {
		return id;
	}

	@Override
	public String getName() {
		return name;
	}

	byte getPriority() {
		return priority;
	}

	@Override
	public IContentTypeSettings getSettings(IScopeContext context) {
		if (context == null || context.equals(manager.getContext()))
			return this;
		return new ContentTypeSettings(this, context);
	}

	/*
	 * Returns the alias target, if one is found, or this object otherwise.
	 */
	ContentType getAliasTarget(boolean self) {
		return (self && target == null) ? this : target;
	}

	byte getValidation() {
		return validation;
	}

	boolean hasBuiltInAssociations() {
		return builtInAssociations;
	}

	boolean hasFileSpec(IScopeContext context, String text, int typeMask) {
		if (context.equals(manager.getContext()) || (typeMask & IGNORE_USER_DEFINED) != 0)
			return hasFileSpec(text, typeMask, false);
		String[] fileSpecs = ContentTypeSettings.getFileSpecs(context, id, typeMask);
		for (String fileSpec : fileSpecs)
			if (text.equalsIgnoreCase(fileSpec))
				return true;
		// no user defined association... try built-in
		return hasFileSpec(text, typeMask | IGNORE_PRE_DEFINED, false);
	}

	/**
	 * Returns whether this content type has the given file spec.
	 *
	 * @param text
	 *            the file spec string
	 * @param typeMask
	 *            FILE_NAME_SPEC or FILE_EXTENSION_SPEC or FILE_REGEXP_SPEC
	 * @param strict
	 * @return true if this file spec has already been added, false otherwise
	 */
	boolean hasFileSpec(String text, int typeMask, boolean strict) {
		if (fileSpecs.isEmpty())
			return false;
		for (FileSpec spec : fileSpecs) {
			if (spec.equals(text, typeMask, strict))
				return true;
		}
		return false;
	}

	@Override
	public int hashCode() {
		return id.hashCode();
	}

	/**
	 * Adds a user-defined or pre-defined file spec.
	 */
	boolean internalAddFileSpec(String fileSpec, int typeMask) {
		if (hasFileSpec(fileSpec, typeMask, false))
			return false;
		FileSpec newFileSpec = createFileSpec(fileSpec, typeMask);
		if ((typeMask & ContentType.SPEC_USER_DEFINED) == 0) {
			// plug-in defined - all that is left to be done is to add it to the list
			if (fileSpecs.isEmpty())
				fileSpecs = new ArrayList<>(3);
			fileSpecs.add(newFileSpec);
			return true;
		}
		// update file specs atomically so threads traversing the list of file specs don't have to synchronize
		@SuppressWarnings("unchecked")
		ArrayList<FileSpec> tmpFileSpecs = (ArrayList<FileSpec>) fileSpecs.clone();
		tmpFileSpecs.add(newFileSpec);
		catalog.associate(this, newFileSpec.getText(), newFileSpec.getType());
		// set the new file specs atomically
		fileSpecs = tmpFileSpecs;
		return true;
	}

	/**
	 * Returns the default value for a property, recursively if necessary.
	 */
	String internalGetDefaultProperty(QualifiedName key) {
		// a special case for charset - users can override
		if (userCharset != null && key.equals(IContentDescription.CHARSET))
			return userCharset;
		String defaultValue = basicGetDefaultProperty(key);
		if (defaultValue != null)
			return defaultValue;
		// not defined here, try base type
		return baseType == null ? null : baseType.internalGetDefaultProperty(key);
	}

	/**
	 * Returns the value of a built-in property defined for this content type.
	 */
	String basicGetDefaultProperty(QualifiedName key) {
		return defaultProperties == null ? null : defaultProperties.get(key);
	}

	BasicDescription internalGetDescriptionFor(ILazySource buffer, QualifiedName[] options) throws IOException {
		if (buffer == null)
			return defaultDescription;
		// use temporary local var to avoid sync'ing
		IContentDescriber tmpDescriber = this.getDescriber();
		// no describer - return default description
		if (tmpDescriber == null)
			return defaultDescription;
		if (buffer.isText() && !(tmpDescriber instanceof ITextContentDescriber))
			// it is an error to provide a Reader to a non-text content type
			throw new UnsupportedOperationException();
		ContentDescription description = new ContentDescription(options, this);
		if (describe(tmpDescriber, buffer, description) == IContentDescriber.INVALID)
			// the contents were actually invalid for the content type
			return null;
		// the describer didn't add any details, return default description
		if (!description.isSet())
			return defaultDescription;
		// description cannot be changed afterwards
		description.markImmutable();
		return description;
	}

	byte internalIsAssociatedWith(String fileName, IScopeContext context) {
		if (hasFileSpec(context, fileName, FILE_NAME_SPEC))
			return ASSOCIATED_BY_NAME;
		String fileExtension = ContentTypeManager.getFileExtension(fileName);
		if (hasFileSpec(context, fileExtension, FILE_EXTENSION_SPEC))
			return ASSOCIATED_BY_EXTENSION;
		// if does not have built-in file specs, delegate to parent (if any)
		if (!hasBuiltInAssociations() && baseType != null)
			return baseType.internalIsAssociatedWith(fileName, context);
		return NOT_ASSOCIATED;
	}

	boolean internalRemoveFileSpec(String fileSpec, int typeMask) {
		if (fileSpecs.isEmpty())
			return false;
		// we modify the list of file specs atomically so we don't interfere with threads doing traversals
		@SuppressWarnings("unchecked")
		ArrayList<FileSpec> tmpFileSpecs = (ArrayList<FileSpec>) fileSpecs.clone();
		for (Iterator<FileSpec> i = tmpFileSpecs.iterator(); i.hasNext();) {
			FileSpec spec = i.next();
			if ((spec.getType() == typeMask) && fileSpec.equals(spec.getText())) {
				i.remove();
				catalog.dissociate(this, spec.getText(), spec.getType());
				// update the list of file specs
				fileSpecs = tmpFileSpecs;
				return true;
			}
		}
		return false;
	}

	public IContentDescriber invalidateDescriber(Throwable reason) {
		String message = NLS.bind(ContentMessages.content_invalidContentDescriber, id);
		log(message, reason);
		return (IContentDescriber) (describer = new InvalidDescriber());
	}

	boolean isAlias() {
		return target != null;
	}

	@Override
	public boolean isAssociatedWith(String fileName) {
		return isAssociatedWith(fileName, manager.getContext());
	}

	@Override
	public boolean isAssociatedWith(String fileName, IScopeContext context) {
		return internalIsAssociatedWith(fileName, context) != NOT_ASSOCIATED;
	}

	@Override
	public boolean isKindOf(IContentType another) {
		if (another == null)
			return false;
		if (this == another)
			return true;
		return baseType != null && baseType.isKindOf(another);
	}

	boolean isValid() {
		return validation == STATUS_VALID;
	}

	void processPreferences(Preferences contentTypeNode) {
		// user set default charset
		this.userCharset = contentTypeNode.get(PREF_DEFAULT_CHARSET, null);
		// user set file names
		String userSetFileNames = contentTypeNode.get(PREF_FILE_NAMES, null);
		String[] fileNames = Util.parseItems(userSetFileNames);
		for (String fileName : fileNames)
			internalAddFileSpec(fileName, FILE_NAME_SPEC | SPEC_USER_DEFINED);
		// user set file extensions
		String userSetFileExtensions = contentTypeNode.get(PREF_FILE_EXTENSIONS, null);
		String[] fileExtensions = Util.parseItems(userSetFileExtensions);
		for (String fileExtension : fileExtensions)
			internalAddFileSpec(fileExtension, FILE_EXTENSION_SPEC | SPEC_USER_DEFINED);
		// user set file name regexp
		String userSetFileRegexp = contentTypeNode.get(PREF_FILE_PATTERNS, null);
		String[] fileRegexps = Util.parseItems(userSetFileRegexp);
		for (String fileRegexp : fileRegexps) {
			internalAddFileSpec(fileRegexp, FILE_PATTERN_SPEC | SPEC_USER_DEFINED);
		}
	}

	@Override
	public void removeFileSpec(String fileSpec, int type) throws CoreException {
		Assert.isLegal(type == FILE_EXTENSION_SPEC || type == FILE_NAME_SPEC || type == FILE_PATTERN_SPEC,
				"Unknown type: " + type); //$NON-NLS-1$
		synchronized (this) {
			if (!internalRemoveFileSpec(fileSpec, type | SPEC_USER_DEFINED))
				return;
		}
		// persist the change
		Preferences contentTypeNode = manager.getPreferences().node(id);
		final String[] userSet = getFileSpecs(type | IGNORE_PRE_DEFINED);
		String preferenceKey = getPreferenceKey(type);
		String newValue = Util.toListString(userSet);
		setPreference(contentTypeNode, preferenceKey, newValue);
		try {
			contentTypeNode.flush();
		} catch (BackingStoreException bse) {
			String message = NLS.bind(ContentMessages.content_errorSavingSettings, id);
			IStatus status = new Status(IStatus.ERROR, ContentMessages.OWNER_NAME, 0, message, bse);
			throw new CoreException(status);
		}
		// notify listeners
		manager.fireContentTypeChangeEvent(this);
	}

	void setAliasTarget(ContentType newTarget) {
		target = newTarget;
	}

	@Override
	public void setDefaultCharset(String newCharset) throws CoreException {
		synchronized (this) {
			// don't do anything if there is no actual change
			if (userCharset == null) {
				if (newCharset == null)
					return;
			} else if (userCharset.equals(newCharset))
				return;
			// apply change in memory
			userCharset = newCharset;
		}
		// persist the change
		Preferences contentTypeNode = manager.getPreferences().node(id);
		setPreference(contentTypeNode, PREF_DEFAULT_CHARSET, userCharset);
		try {
			contentTypeNode.flush();
		} catch (BackingStoreException bse) {
			String message = NLS.bind(ContentMessages.content_errorSavingSettings, id);
			IStatus status = new Status(IStatus.ERROR, ContentMessages.OWNER_NAME, 0, message, bse);
			throw new CoreException(status);
		}
		// notify listeners
		manager.fireContentTypeChangeEvent(this);
	}

	static void setPreference(Preferences node, String key, String value) {
		if (value == null)
			node.remove(key);
		else
			node.put(key, value);
	}

	void setValidation(byte validation) {
		this.validation = validation;
		if (ContentTypeManager.DEBUGGING)
			ContentMessages.message("Validating " + this + ": " + getValidationString(validation)); //$NON-NLS-1$ //$NON-NLS-2$
	}

	@Override
	public String toString() {
		return id;
	}

	void setBaseType(ContentType baseType) {
		this.baseType = baseType;
	}

	@Override
	public boolean isUserDefined() {
		return this.contentTypeElement == null;
	}

}
