package ezvcard.io.scribe;

import static ezvcard.util.TestUtils.assertParseWarnings;
import static org.custommonkey.xmlunit.XMLAssert.assertXMLEqual;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;

import java.util.List;
import java.util.Map;

import javax.xml.namespace.QName;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.xml.sax.SAXException;

import ezvcard.VCard;
import ezvcard.VCardDataType;
import ezvcard.VCardVersion;
import ezvcard.io.CannotParseException;
import ezvcard.io.ParseContext;
import ezvcard.io.SkipMeException;
import ezvcard.io.html.HCardElement;
import ezvcard.io.json.JCardValue;
import ezvcard.io.text.WriteContext;
import ezvcard.parameter.VCardParameters;
import ezvcard.property.VCardProperty;
import ezvcard.util.HtmlUtils;
import ezvcard.util.XmlUtils;

/*
 Copyright (c) 2012-2020, Michael Angstadt
 All rights reserved.

 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are met: 

 1. Redistributions of source code must retain the above copyright notice, this
 list of conditions and the following disclaimer. 
 2. Redistributions in binary form must reproduce the above copyright notice,
 this list of conditions and the following disclaimer in the documentation
 and/or other materials provided with the distribution. 

 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/**
 * Utility class used for unit testing property scribes.
 * @param <T> the property class
 * @author Michael Angstadt
 */
public class Sensei<T extends VCardProperty> {
	private final VCardPropertyScribe<T> scribe;

	/**
	 * Shorthand for creating a new instance of this class.
	 * @param scribe the property scribe
	 * @return the new instance
	 */
	public static <T extends VCardProperty> Sensei<T> create(VCardPropertyScribe<T> scribe) {
		return new Sensei<T>(scribe);
	}

	/**
	 * Creates a new sensei.
	 * @param scribe the property scribe
	 */
	public Sensei(VCardPropertyScribe<T> scribe) {
		this.scribe = scribe;
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#parseText} method.
	 * @param value the value to parse
	 * @return the tester object
	 */
	public ParseTextTest assertParseText(String value) {
		return new ParseTextTest(value);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#parseXml} method.
	 * @param innerXml the inner XML of the xCal element to parse
	 * @return the tester object
	 */
	public ParseXmlTest assertParseXml(String innerXml) {
		return new ParseXmlTest(innerXml);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#parseHtml} method.
	 * @param html the HTML element to parse
	 * @return the tester object
	 */
	public ParseHtmlTest assertParseHtml(String html) {
		return new ParseHtmlTest(html);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#parseJson} method.
	 * @param value the jCal value to parse
	 * @return the tester object
	 */
	public ParseJsonTest assertParseJson(String value) {
		return assertParseJson(JCardValue.single(value));
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#parseJson} method.
	 * @param value the jCal value to parse
	 * @return the tester object
	 */
	public ParseJsonTest assertParseJson(JCardValue value) {
		return new ParseJsonTest(value);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#dataType} method.
	 * @param property the property to marshal
	 * @return the tester object
	 */
	public DataTypeTest assertDataType(T property) {
		return new DataTypeTest(property);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#prepareParameters} method.
	 * @param property the property to marshal
	 * @return the tester object
	 */
	public PrepareParamsTest assertPrepareParams(T property) {
		return new PrepareParamsTest(property);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#writeText} method.
	 * @param property the property to marshal
	 * @return the tester object
	 */
	public WriteTextTest assertWriteText(T property) {
		return new WriteTextTest(property);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#writeXml} method.
	 * @param property the property to marshal
	 * @return the tester object
	 */
	public WriteXmlTest assertWriteXml(T property) {
		return new WriteXmlTest(property);
	}

	/**
	 * Asserts the {@link VCardPropertyScribe#writeJson} method.
	 * @param property the property to marshal
	 * @return the tester object
	 */
	public WriteJsonTest assertWriteJson(T property) {
		return new WriteJsonTest(property);
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#dataType}
	 * method.
	 */
	public class DataTypeTest {
		protected final T property;
		private VCardVersion version[] = VCardVersion.values();

		public DataTypeTest(T property) {
			this.property = property;
		}

		/**
		 * Sets the versions to run this test under (defaults to all versions).
		 * @param version the versions
		 * @return this
		 */
		public DataTypeTest versions(VCardVersion... version) {
			this.version = version;
			return this;
		}

		/**
		 * Runs the test.
		 * @param expected the expected data type
		 */
		public void run(VCardDataType expected) {
			for (VCardVersion version : this.version) {
				VCardDataType actual = scribe.dataType(property, version);
				assertEquals("Version " + version, expected, actual);
			}
		}
	}

	/**
	 * Tester class used for testing the
	 * {@link VCardPropertyScribe#prepareParameters} method.
	 */
	public class PrepareParamsTest {
		protected final T property;
		private VCardVersion versions[] = VCardVersion.values();
		private VCard vcard = new VCard();
		private VCardParameters expected = new VCardParameters();

		public PrepareParamsTest(T property) {
			this.property = property;
		}

		/**
		 * Sets the versions to run this test under (defaults to all versions).
		 * @param versions the versions
		 * @return this
		 */
		public PrepareParamsTest versions(VCardVersion... versions) {
			this.versions = versions;
			return this;
		}

		/**
		 * Sets the {@link VCard} object to use (defaults to an empty vCard).
		 * @param vcard the vCard
		 * @return this
		 */
		public PrepareParamsTest vcard(VCard vcard) {
			this.vcard = vcard;
			return this;
		}

		/**
		 * Adds an expected parameter.
		 * @param name the parameter name
		 * @param values the parameter value
		 * @return this
		 */
		public PrepareParamsTest expected(String name, String... values) {
			for (String value : values) {
				expected.put(name, value);
			}
			return this;
		}

		/**
		 * Runs the test.
		 */
		public void run() {
			for (VCardVersion version : versions) {
				VCardParameters actual = scribe.prepareParameters(property, version, vcard);
				assertEquals("Actual: " + actual, expected.size(), actual.size());

				for (Map.Entry<String, List<String>> entry : expected) {
					String expectedKey = entry.getKey();
					List<String> expectedValues = entry.getValue();

					List<String> actualValues = actual.get(expectedKey);
					assertEquals("Actual: " + actual, expectedValues.size(), actualValues.size());
					for (String expectedValue : expectedValues) {
						assertTrue("Actual: " + actual, actualValues.contains(expectedValue));
					}
				}
			}
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#writeText}
	 * method.
	 */
	public class WriteTextTest {
		protected final T property;
		private VCardVersion versions[] = VCardVersion.values();
		private boolean includeTrailingSemicolons = false;

		public WriteTextTest(T property) {
			this.property = property;
		}

		/**
		 * Sets the versions to run this test under (defaults to all versions).
		 * @param versions the versions
		 * @return this
		 */
		public WriteTextTest versions(VCardVersion... versions) {
			this.versions = versions;
			return this;
		}

		/**
		 * Sets whether to include trailing semicolons for structured property
		 * values whose list of values end with null or empty values
		 * @param include true to include them, false not to (defaults to false)
		 * @return this
		 */
		public WriteTextTest includeTrailingSemicolons(boolean include) {
			includeTrailingSemicolons = include;
			return this;
		}

		/**
		 * Runs the test, expecting a {@link SkipMeException} to be thrown.
		 */
		public void skipMe() {
			run(SkipMeException.class);
		}

		/**
		 * Runs the test.
		 * @param expected the expected property value
		 */
		public void run(String expected) {
			for (VCardVersion version : versions) {
				String actual = scribe.writeText(property, new WriteContext(version, null, includeTrailingSemicolons));
				assertEquals("Version " + version, expected, actual);
			}
		}

		public void run(Class<? extends RuntimeException> expected) {
			for (VCardVersion version : versions) {
				try {
					scribe.writeText(property, new WriteContext(version, null, false));
					fail("Expected " + expected.getSimpleName());
				} catch (RuntimeException t) {
					assertEquals("Expected " + expected.getSimpleName() + ", but was " + t.getClass().getSimpleName(), expected, t.getClass());
				}
			}
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#writeXml}
	 * method.
	 */
	public class WriteXmlTest {
		protected final T property;

		public WriteXmlTest(T property) {
			this.property = property;
		}

		/**
		 * Runs the test.
		 * @param expectedInnerXml the expected inner XML of the xCard property
		 * element
		 */
		public void run(String expectedInnerXml) {
			Document actual = createXCardElement();
			scribe.writeXml(property, actual.getDocumentElement());

			Document expected = createXCardElement(expectedInnerXml);

			assertXMLEqual(XmlUtils.toString(actual), expected, actual);
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#writeJson}
	 * method.
	 */
	public class WriteJsonTest {
		protected final T property;

		public WriteJsonTest(T property) {
			this.property = property;
		}

		/**
		 * Runs the test.
		 * @return the marshalled value
		 */
		public JCardValue run() {
			return scribe.writeJson(property);
		}

		/**
		 * Runs the test.
		 * @param expected the expected jCard value
		 */
		public void run(JCardValue expected) {
			JCardValue value = run();
			assertEquals(expected.getValues(), value.getValues());
		}

		/**
		 * Runs the test.
		 * @param expected the expected jCard value
		 */
		public void run(String expected) {
			JCardValue value = run();
			assertEquals(1, value.getValues().size());
			assertEquals(expected, value.getValues().get(0).getValue());
		}
	}

	/**
	 * Parent class for the parser testers.
	 */
	private abstract class ParseTest<U extends ParseTest<U>> {
		protected VCardParameters parameters = new VCardParameters();
		protected Integer[] parseWarningCodes = new Integer[0];

		@SuppressWarnings("unchecked")
		private final U this_ = (U) this;

		/**
		 * Adds an expected parameter.
		 * @param name the parameter name
		 * @param value the parameter value
		 * @return this
		 */
		public U param(String name, String value) {
			parameters.put(name, value);
			return this_;
		}

		/**
		 * Sets the expected parameters.
		 * @param parameters the parameters
		 * @return this
		 */
		public U params(VCardParameters parameters) {
			this.parameters = parameters;
			return this_;
		}

		/**
		 * Sets the expected parse warning codes (by default, the scribe is not
		 * expected to return any parse warnings).
		 * @param codes the expected parse warning codes (order does not matter)
		 * @return this
		 */
		public U warnings(Integer... codes) {
			parseWarningCodes = codes;
			return this_;
		}

		/**
		 * Runs the test, without testing the returned property object.
		 */
		public void run() {
			run(null, -1);
		}

		/**
		 * Runs the test.
		 * @param check object for validating the parsed property object
		 */
		public void run(Check<T> check) {
			run(check, -1);
		}

		/**
		 * Runs the test.
		 * @param expected the expected property. Expected and actual property
		 * objects will be compared with the {@code equals()} method.
		 */
		public void run(final T expected) {
			run(new Check<T>() {
				public void check(T actual) {
					assertEquals(expected, actual);
				}
			}, -1);
		}

		/**
		 * Runs the test, expecting a {@link CannotParseException} to be thrown.
		 * @param expectedCode the expected warning code of the exception, or
		 * null if the exception shouldn't have a warning code
		 */
		public void cannotParse(Integer expectedCode) {
			run(null, expectedCode);
		}

		/**
		 * Runs the test.
		 * @param check object for asserting the parsed property object or null
		 * not to assert the property object
		 * @param cannotParseExceptionCode if the parse operation is expected to throw a
		 * {@link CannotParseException}, this parameter is the expected warning
		 * code of the exception. This should be null if the exception is not
		 * expected to have a warning code. This should be -1 if a
		 * {@link CannotParseException} is not expected to be thrown
		 */
		protected abstract void run(Check<T> check, Integer cannotParseExceptionCode);
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#parseText}
	 * method.
	 */
	public class ParseTextTest extends ParseTest<ParseTextTest> {
		private final String value;
		private VCardDataType dataType;
		private VCardVersion versions[] = VCardVersion.values();

		/**
		 * @param value the text to parse
		 */
		public ParseTextTest(String value) {
			this.value = value;
		}

		/**
		 * Sets the data type (defaults to the property's default data type)
		 * @param dataType the data type
		 * @return this
		 */
		public ParseTextTest dataType(VCardDataType dataType) {
			this.dataType = dataType;
			return this;
		}

		/**
		 * Sets the versions to run this test under (defaults to all versions).
		 * @param versions the versions
		 * @return this
		 */
		public ParseTextTest versions(VCardVersion... versions) {
			this.versions = versions;
			return this;
		}

		@Override
		protected void run(Check<T> check, Integer cannotParseExceptionCode) {
			for (VCardVersion version : versions) {
				VCardDataType dataType = this.dataType;
				if (dataType == null) {
					dataType = scribe.defaultDataType(version);
				}

				ParseContext context = new ParseContext();
				context.setVersion(version);
				try {
					T property = scribe.parseText(value, dataType, parameters, context);

					if (cannotParseExceptionCode == null || cannotParseExceptionCode >= 0) {
						fail("Expected a CannotParseException with code <" + cannotParseExceptionCode + "> to be thrown.");
					}

					if (check != null) {
						check.check(property);
					}

					assertParseWarnings(context.getWarnings(), parseWarningCodes);
				} catch (CannotParseException e) {
					if (cannotParseExceptionCode == -1) {
						/*
						 * Throw the exception to fail the test, since it was
						 * not expected to be thrown.
						 */
						throw e;
					}

					assertEquals("CannotParseException's parse warning code was wrong.", cannotParseExceptionCode, e.getCode());
				}
			}
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#parseXml}
	 * method.
	 */
	public class ParseXmlTest extends ParseTest<ParseXmlTest> {
		private final String innerXml;

		/**
		 * @param innerXml the inner XML of the xCard property element
		 */
		public ParseXmlTest(String innerXml) {
			this.innerXml = innerXml;
		}

		@Override
		protected void run(Check<T> check, Integer cannotParseExceptionCode) {
			try {
				ParseContext context = new ParseContext();
				context.setVersion(VCardVersion.V4_0);
				Document document = createXCardElement(innerXml);
				Element element = document.getDocumentElement();
				T property = scribe.parseXml(element, parameters, context);

				if (cannotParseExceptionCode == null || cannotParseExceptionCode >= 0) {
					fail("Expected a CannotParseException with code <" + cannotParseExceptionCode + "> to be thrown.");
				}

				if (check != null) {
					check.check(property);
				}

				assertParseWarnings(context.getWarnings(), parseWarningCodes);
			} catch (CannotParseException e) {
				if (cannotParseExceptionCode == -1) {
					/*
					 * Throw the exception to fail the test, since it was not
					 * expected to be thrown.
					 */
					throw e;
				}

				assertEquals("CannotParseException's parse warning code was wrong.", cannotParseExceptionCode, e.getCode());
			}
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#parseHtml}
	 * method.
	 */
	public class ParseHtmlTest extends ParseTest<ParseHtmlTest> {
		private final String html;

		/**
		 * @param html the HTML of the hCard property element
		 */
		public ParseHtmlTest(String html) {
			this.html = html;
		}

		@Override
		public ParseHtmlTest param(String name, String value) {
			throw new UnsupportedOperationException();
		}

		@Override
		public ParseHtmlTest params(VCardParameters parameters) {
			throw new UnsupportedOperationException();
		}

		@Override
		protected void run(Check<T> check, Integer cannotParseExceptionCode) {
			try {
				ParseContext context = new ParseContext();
				context.setVersion(VCardVersion.V3_0);
				org.jsoup.nodes.Element element = HtmlUtils.toElement(html);
				T property = scribe.parseHtml(new HCardElement(element), context);

				if (cannotParseExceptionCode == null || cannotParseExceptionCode >= 0) {
					fail("Expected a CannotParseException with code <" + cannotParseExceptionCode + "> to be thrown.");
				}

				if (check != null) {
					check.check(property);
				}

				assertParseWarnings(context.getWarnings(), parseWarningCodes);
			} catch (CannotParseException e) {
				if (cannotParseExceptionCode == -1) {
					/*
					 * Throw the exception to fail the test, since it was not
					 * expected to be thrown.
					 */
					throw e;
				}

				assertEquals("CannotParseException's parse warning code was wrong.", cannotParseExceptionCode, e.getCode());
			}
		}
	}

	/**
	 * Tester class used for testing the {@link VCardPropertyScribe#parseJson}
	 * method.
	 */
	public class ParseJsonTest extends ParseTest<ParseJsonTest> {
		private final JCardValue value;
		private VCardDataType dataType = scribe.defaultDataType(VCardVersion.V4_0);

		/**
		 * @param value the value
		 */
		public ParseJsonTest(JCardValue value) {
			this.value = value;
		}

		/**
		 * Sets the data type (defaults to the property's default data type)
		 * @param dataType the data type
		 * @return this
		 */
		public ParseJsonTest dataType(VCardDataType dataType) {
			this.dataType = dataType;
			return this;
		}

		@Override
		protected void run(Check<T> check, Integer cannotParseExceptionCode) {
			try {
				ParseContext context = new ParseContext();
				context.setVersion(VCardVersion.V4_0);
				T property = scribe.parseJson(value, dataType, parameters, context);

				if (cannotParseExceptionCode == null || cannotParseExceptionCode >= 0) {
					fail("Expected a CannotParseException with code <" + cannotParseExceptionCode + "> to be thrown.");
				}

				if (check != null) {
					check.check(property);
				}

				assertParseWarnings(context.getWarnings(), parseWarningCodes);
			} catch (CannotParseException e) {
				if (cannotParseExceptionCode == -1) {
					/*
					 * Throw the exception to fail the test, since it was not
					 * expected to be thrown.
					 */
					throw e;
				}

				assertEquals("CannotParseException's parse warning code was wrong.", cannotParseExceptionCode, e.getCode());
			}
		}
	}

	/**
	 * Used for validating the contents of a parsed property object.
	 * @param <T> the property class
	 */
	public interface Check<T extends VCardProperty> {
		/**
		 * Validates the contents of the parsed property object.
		 * @param property the parsed property object
		 */
		void check(T property);
	}

	private Document createXCardElement() {
		return createXCardElement("");
	}

	private Document createXCardElement(String innerXml) {
		QName qname = scribe.getQName();
		String localName = qname.getLocalPart();
		String ns = qname.getNamespaceURI();

		try {
			return XmlUtils.toDocument("<" + localName + " xmlns=\"" + ns + "\">" + innerXml + "</" + localName + ">");
		} catch (SAXException e) {
			throw new RuntimeException(e);
		}
	}
}
