/*
 * Copyright 2014-2019 Web Firm Framework
 *
 * 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 com.webfirmframework.wffweb.server.page;

import java.io.IOException;
import java.io.OutputStream;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.webfirmframework.wffweb.InvalidUsageException;
import com.webfirmframework.wffweb.InvalidValueException;
import com.webfirmframework.wffweb.NotRenderedException;
import com.webfirmframework.wffweb.NullValueException;
import com.webfirmframework.wffweb.PushFailedException;
import com.webfirmframework.wffweb.server.page.js.WffJsFile;
import com.webfirmframework.wffweb.tag.html.AbstractHtml;
import com.webfirmframework.wffweb.tag.html.Html;
import com.webfirmframework.wffweb.tag.html.TagNameConstants;
import com.webfirmframework.wffweb.tag.html.attribute.Defer;
import com.webfirmframework.wffweb.tag.html.attribute.Nonce;
import com.webfirmframework.wffweb.tag.html.attribute.Src;
import com.webfirmframework.wffweb.tag.html.attribute.Type;
import com.webfirmframework.wffweb.tag.html.attribute.core.AbstractAttribute;
import com.webfirmframework.wffweb.tag.html.attribute.event.EventAttribute;
import com.webfirmframework.wffweb.tag.html.attribute.event.ServerAsyncMethod;
import com.webfirmframework.wffweb.tag.html.attribute.listener.AttributeValueChangeListener;
import com.webfirmframework.wffweb.tag.html.html5.attribute.global.DataWffId;
import com.webfirmframework.wffweb.tag.html.programming.Script;
import com.webfirmframework.wffweb.tag.htmlwff.NoTag;
import com.webfirmframework.wffweb.tag.repository.TagRepository;
import com.webfirmframework.wffweb.util.HashUtil;
import com.webfirmframework.wffweb.util.WffBinaryMessageUtil;
import com.webfirmframework.wffweb.util.data.NameValue;
import com.webfirmframework.wffweb.wffbm.data.WffBMObject;

/**
 * @author WFF
 * @since 2.0.0
 */
public abstract class BrowserPage implements Serializable {

    // if this class' is refactored then SecurityClassConstants should be
    // updated.

    private static final long serialVersionUID = 1_0_1L;

    public static final Logger LOGGER = Logger
            .getLogger(BrowserPage.class.getName());

    public static final String WFF_INSTANCE_ID = "wffInstanceId";

    private static final boolean PRODUCTION_MODE = true;

    private final String instanceId = UUID.randomUUID().toString();

    private AttributeValueChangeListener valueChangeListener;

    private Map<String, AbstractHtml> tagByWffId;

    private volatile AbstractHtml rootTag;

    private volatile boolean wsWarningDisabled;

    private final Map<String, WebSocketPushListener> sessionIdWsListeners = new ConcurrentHashMap<>();

    private final Deque<WebSocketPushListener> wsListeners = new ArrayDeque<>();

    private WebSocketPushListener wsListener;

    private DataWffId wffScriptTagId;

    /**
     * it's true by default since 3.0.1
     */
    private boolean enableDeferOnWffScript = true;

    private Nonce nonceForWffScriptTag;

    private boolean autoremoveWffScript = true;

    private volatile boolean renderInvoked;

    // ConcurrentLinkedQueue give better performance than ConcurrentLinkedDeque
    // on benchmark
    private final Deque<ByteBuffer> wffBMBytesQueue = new ConcurrentLinkedDeque<>();

    // ConcurrentLinkedQueue give better performance than ConcurrentLinkedDeque
    // on benchmark
    private final Queue<ByteBuffer> wffBMBytesHoldPushQueue = new ConcurrentLinkedQueue<>();

    private static final Security ACCESS_OBJECT = new Security();

    // by default the push queue should be enabled
    private boolean pushQueueEnabled = true;

    // by default the pushQueueOnNewWebSocketListener should be enabled
    private boolean pushQueueOnNewWebSocketListener = true;

    private final AtomicInteger holdPush = new AtomicInteger(0);

    private final Map<String, ServerMethod> serverMethods = new ConcurrentHashMap<>();

    private boolean removeFromBrowserContextOnTabClose = true;

    private boolean removePrevFromBrowserContextOnTabInit = true;

    private int wsHeartbeatInterval = -1;

    private int wsReconnectInterval = -1;

    private static int wsDefaultHeartbeatInterval = 25_000;

    private static int wsDefaultReconnectInterval = 2_000;

    private final LongAdder pushQueueSize = new LongAdder();

    // there will be only one thread waiting for the lock so fairness must be
    // false and fairness may decrease the lock time
    private final ReentrantLock pushWffBMBytesQueueLock = new ReentrantLock(
            false);

    private final AtomicReference<Thread> waitingThreadRef = new AtomicReference<>();

    private volatile TagRepository tagRepository;

    // inline initialization is better because object creation is not heavy.
    private final ThreadLocal<PayloadProcessor> PALYLOAD_PROCESSOR_TL = ThreadLocal
            .withInitial(() -> new PayloadProcessor(this));

    // for security purpose, the class name should not be modified
    private static final class Security implements Serializable {

        private static final long serialVersionUID = 1L;

        private Security() {
        }
    }

    /**
     * To specify (by removeFromContext method) when to remove
     * {@code BrowserPage} instance from {@code BrowserPageContext}.
     *
     * @author WFF
     * @since 2.1.4
     */
    public static enum On {

        /**
         * to remove the current {@code BrowserPage} instance from
         * {@code BrowserPageContext} when the tab/window is closed.
         */
        TAB_CLOSE,

        /**
         * To remove the previous {@code BrowserPage} instance opened in the
         * same tab when new {@code BrowserPage} is requested by the tab.
         */
        INIT_REMOVE_PREVIOUS;
    }

    public abstract String webSocketUrl();

    /**
     * @param wsListener
     * @since 2.0.0
     * @author WFF
     */
    public final void setWebSocketPushListener(
            final WebSocketPushListener wsListener) {
        this.wsListener = wsListener;
        if (pushQueueOnNewWebSocketListener) {
            pushWffBMBytesQueue();
        }
    }

    /**
     * adds the WebSocket listener for the given WebSocket session
     *
     * @param sessionId
     *                       the unique id of WebSocket session
     * @param wsListener
     * @since 2.1.0
     * @author WFF
     */
    public final void addWebSocketPushListener(final String sessionId,
            final WebSocketPushListener wsListener) {

        sessionIdWsListeners.put(sessionId, wsListener);
        wsListeners.push(wsListener);

        this.wsListener = wsListener;

        if (pushQueueOnNewWebSocketListener) {
            pushWffBMBytesQueue();
        }

    }

    /**
     * removes the WebSocket listener added for this WebSocket session
     *
     * @param sessionId
     *                      the unique id of WebSocket session
     * @since 2.1.0
     * @author WFF
     */
    public final void removeWebSocketPushListener(final String sessionId) {

        final WebSocketPushListener removed = sessionIdWsListeners
                .remove(sessionId);
        wsListeners.remove(removed);
        wsListener = wsListeners.peek();
    }

    public final WebSocketPushListener getWsListener() {
        return wsListener;
    }

    final void push(final NameValue... nameValues) {
        push(ByteBuffer.wrap(WffBinaryMessageUtil.VERSION_1
                .getWffBinaryMessageBytes(nameValues)));
    }

    private void push(final ByteBuffer wffBM) {

        if (holdPush.get() > 0) {
            // add method internally calls offer method in ConcurrentLinkedQueue
            wffBMBytesHoldPushQueue.offer(wffBM);
        } else {
            // add method internally calls offer which internally
            // calls offerLast method in ConcurrentLinkedQueue

            if (wffBMBytesQueue.offerLast(wffBM)) {
                pushQueueSize.increment();
            }
        }
    }

    private void pushWffBMBytesQueue() {

        if (wsListener != null) {

            // hasQueuedThreads internally uses transient volatile Node
            // so it must be fine for production use but
            // TODO verify it in deep if it is good for production
            if (!pushWffBMBytesQueueLock.hasQueuedThreads()
                    && !wffBMBytesQueue.isEmpty()) {
                try {
                    waitingThreadRef.set(Thread.currentThread());
                    pushWffBMBytesQueueLock.lock();

                    // wsPushInProgress must be implemented here and it is very
                    // important because multiple threads should not push
                    // simultaneously
                    // from same wffBMBytesQueue which will cause incorrect
                    // order of
                    // push

                    ByteBuffer byteBuffer = wffBMBytesQueue.poll();

                    if (byteBuffer != null) {

                        final Thread currentThread = Thread.currentThread();
                        do {
                            pushQueueSize.decrement();

                            try {
                                wsListener.push(byteBuffer);
                            } catch (final PushFailedException e) {
                                if (pushQueueEnabled && wffBMBytesQueue
                                        .offerFirst(byteBuffer)) {
                                    pushQueueSize.increment();
                                }

                                break;
                            } catch (final NullPointerException e) {
                                if (wffBMBytesQueue.offerFirst(byteBuffer)) {
                                    pushQueueSize.increment();
                                }
                                break;
                            }

                            if (pushWffBMBytesQueueLock.hasQueuedThreads()
                                    && waitingThreadRef.get()
                                            .getPriority() >= currentThread
                                                    .getPriority()) {
                                break;
                            }

                            byteBuffer = wffBMBytesQueue.poll();

                        } while (byteBuffer != null);
                    }

                } finally {
                    pushWffBMBytesQueueLock.unlock();
                }
            }
        } else {
            if (LOGGER.isLoggable(Level.WARNING) && !wsWarningDisabled) {
                LOGGER.warning(
                        "There is no WebSocket listener set, set it with BrowserPage#setWebSocketPushListener method.");
            }
        }
    }

    final DataWffId getNewDataWffId() {
        return rootTag.getSharedObject().getNewDataWffId(ACCESS_OBJECT);
    }

    /**
     * This method will be remove later. Use {@code webSocketMessaged}.
     *
     * @param message
     *                    the bytes the received in onmessage
     * @since 2.0.0
     * @author WFF
     * @deprecated alternative method webSocketMessaged is available for the
     *             same job.
     *
     */
    @Deprecated
    public final void websocketMessaged(final byte[] message) {
        webSocketMessaged(message);
    }

    /**
     * @param message
     *                    the bytes the received in onmessage
     * @since 2.1.0
     * @author WFF
     */
    public final void webSocketMessaged(final byte[] message) {
        try {

            // TODO minimum number of an empty bm message length is 4
            // below that length is not a valid bm message so check
            // message.length < 4
            // later if there is such requirement
            if (message.length == 0) {
                // should not proceed if the message.length is zero because
                // to avoid exception if the client sends an empty message just
                // for ping
                return;
            }

            executeWffBMTask(message);
        } catch (final Exception e) {
            if (!PRODUCTION_MODE) {
                e.printStackTrace();
            }
            if (LOGGER.isLoggable(Level.SEVERE)) {
                LOGGER.log(Level.SEVERE,
                        "Could not process this data received from client.", e);
            }
        }
    }

    /**
     * Invokes just before {@link BrowserPage#render()} method. This is an empty
     * method in BrowserPage. Override and use. This method invokes only once
     * per object in all of its life time.
     *
     * @since 3.0.1
     */
    protected void beforeRender() {
        // NOP override and use
    }

    /**
     * Override and use this method to render html content to the client browser
     * page. This method invokes only once per object in all of its life time.
     *
     * @return the object of {@link Html} class which needs to be displayed in
     *         the client browser page.
     * @author WFF
     */
    public abstract AbstractHtml render();

    /**
     * Invokes after {@link BrowserPage#render()} method. This is an empty
     * method in BrowserPage. Override and use. This method invokes only once
     * per object in all of its life time.
     *
     * @param rootTag
     *                    the rootTag returned by {@link BrowserPage#render()}
     *                    method.
     *
     * @since 3.0.1
     */
    protected void afterRender(final AbstractHtml rootTag) {
        // NOP override and use
    }

    /**
     * Invokes before any of the {@code toHtmlString} or {@code toOutputStream}
     * methods invoked. This is an empty method in BrowserPage. Override and use
     * it. This method will invoke every time before any of the following
     * methods is called, {@code toHtmlString}, {@code toOutputStream} etc..
     *
     *
     * @param rootTag
     *                    the rootTag returned by {@link BrowserPage#render()}
     *                    method.
     *
     * @since 3.0.1
     */
    protected void beforeToHtml(final AbstractHtml rootTag) {
        // NOP override and use
    }

    /**
     * Invokes after any of the {@code toHtmlString} or {@code toOutputStream}
     * methods invoked. This is an empty method in BrowserPage. Override and use
     * it. This method will invoke every time after any of the following methods
     * is called, {@code toHtmlString}, {@code toOutputStream} etc..
     *
     *
     * @param rootTag
     *                    the rootTag returned by {@link BrowserPage#render()}
     *                    method.
     *
     * @since 3.0.1
     */
    protected void afterToHtml(final AbstractHtml rootTag) {
        // NOP override and use
    }

    /**
     * @param nameValues
     * @throws UnsupportedEncodingException
     *                                          throwing this exception will be
     *                                          removed in future version
     *                                          because its internal
     *                                          implementation will never make
     *                                          this exception due to the code
     *                                          changes since 3.0.1.
     */
    private void invokeAsychMethod(final List<NameValue> nameValues)
            throws UnsupportedEncodingException {
        //@formatter:off
        // invoke method task format :-
        // { "name": task_byte, "values" : [invoke_method_byte_from_Task_enum]}, { "name": data-wff-id, "values" : [ event_attribute_name ]}
        // { "name": 2, "values" : [[0]]}, { "name":"C55", "values" : ["onclick"]}
        //@formatter:on

        final NameValue wffTagIdAndAttrName = nameValues.get(1);
        final byte[] intBytes = new byte[wffTagIdAndAttrName.getName().length
                - 1];
        System.arraycopy(wffTagIdAndAttrName.getName(), 1, intBytes, 0,
                intBytes.length);

        final String wffTagId = new String(wffTagIdAndAttrName.getName(), 0, 1,
                StandardCharsets.UTF_8)
                + WffBinaryMessageUtil.getIntFromOptimizedBytes(intBytes);

        final byte[][] values = wffTagIdAndAttrName.getValues();
        final String eventAttrName = new String(values[0],
                StandardCharsets.UTF_8);

        WffBMObject wffBMObject = null;
        if (values.length > 1) {
            final byte[] wffBMObjectBytes = values[1];
            wffBMObject = new WffBMObject(wffBMObjectBytes, true);

        }

        final AbstractHtml methodTag = tagByWffId.get(wffTagId);
        if (methodTag != null) {

            final AbstractAttribute attributeByName = methodTag
                    .getAttributeByName(eventAttrName);

            if (attributeByName != null) {

                if (attributeByName instanceof EventAttribute) {

                    final EventAttribute eventAttr = (EventAttribute) attributeByName;

                    final ServerAsyncMethod serverAsyncMethod = eventAttr
                            .getServerAsyncMethod();

                    final ServerAsyncMethod.Event event = new ServerAsyncMethod.Event(
                            methodTag, attributeByName,
                            eventAttr.getServerSideData());

                    final WffBMObject returnedObject = serverAsyncMethod
                            .asyncMethod(wffBMObject, event);

                    final String jsPostFunctionBody = eventAttr
                            .getJsPostFunctionBody();

                    if (jsPostFunctionBody != null) {

                        final NameValue invokePostFunTask = Task.INVOKE_POST_FUNCTION
                                .getTaskNameValue();
                        final NameValue nameValue = new NameValue();
                        // name as function body string and value at
                        // zeroth index as
                        // wffBMObject bytes
                        nameValue.setName(jsPostFunctionBody
                                .getBytes(StandardCharsets.UTF_8));

                        if (returnedObject != null) {
                            nameValue.setValues(new byte[][] {
                                    returnedObject.buildBytes(true) });
                        }

                        push(invokePostFunTask, nameValue);
                        if (holdPush.get() == 0) {
                            pushWffBMBytesQueue();
                        }
                    }

                } else {
                    LOGGER.severe(attributeByName
                            + " is NOT instanceof EventAttribute");
                }

            } else {
                LOGGER.severe(
                        "no event attribute found for " + attributeByName);
            }

        } else {
            if (!PRODUCTION_MODE) {
                LOGGER.severe("No tag found for wffTagId " + wffTagId);
            }
        }
    }

    /**
     * @param nameValues
     * @throws UnsupportedEncodingException
     *                                          throwing this exception will be
     *                                          removed in future version
     *                                          because its internal
     *                                          implementation will never make
     *                                          this exception due to the code
     *                                          changes since 3.0.1.
     */
    private void removeBrowserPageFromContext(final List<NameValue> nameValues)
            throws UnsupportedEncodingException {
        //@formatter:off
        // invoke custom server method task format :-
        // { "name": task_byte, "values" : [remove_browser_page_byte_from_Task_enum]},
        // { "name": wff-instance-id-bytes, "values" : []}
        //@formatter:on

        final NameValue instanceIdNameValue = nameValues.get(1);

        final String instanceIdToRemove = new String(
                instanceIdNameValue.getName(), StandardCharsets.UTF_8);

        BrowserPageContext.INSTANCE.removeBrowserPage(getInstanceId(),
                instanceIdToRemove);
    }

    /**
     * @param nameValues
     * @throws UnsupportedEncodingException
     *                                          throwing this exception will be
     *                                          removed in future version
     *                                          because its internal
     *                                          implementation will never make
     *                                          this exception due to the code
     *                                          changes since 3.0.1.
     */
    private void invokeCustomServerMethod(final List<NameValue> nameValues)
            throws UnsupportedEncodingException {
        //@formatter:off
        // invoke custom server method task format :-
        // { "name": task_byte, "values" : [invoke_custom_server_method_byte_from_Task_enum]},
        // { "name": server method name bytes, "values" : [ wffBMObject bytes ]}
        // { "name": callback function id bytes, "values" : [ ]}
        //@formatter:on

        final NameValue methodNameAndArg = nameValues.get(1);
        final String methodName = new String(methodNameAndArg.getName(),
                StandardCharsets.UTF_8);

        final ServerMethod serverMethod = serverMethods.get(methodName);

        if (serverMethod != null) {

            final byte[][] values = methodNameAndArg.getValues();

            WffBMObject wffBMObject = null;

            if (values.length > 0) {
                wffBMObject = new WffBMObject(values[0], true);
            }

            final WffBMObject returnedObject = serverMethod
                    .getServerAsyncMethod().asyncMethod(wffBMObject,
                            new ServerAsyncMethod.Event(methodName,
                                    serverMethod.getServerSideData()));

            String callbackFunId = null;

            if (nameValues.size() > 2) {
                final NameValue callbackFunNameValue = nameValues.get(2);
                callbackFunId = new String(callbackFunNameValue.getName(),
                        StandardCharsets.UTF_8);
            }

            if (callbackFunId != null) {
                final NameValue invokeCallbackFuncTask = Task.INVOKE_CALLBACK_FUNCTION
                        .getTaskNameValue();

                final NameValue nameValue = new NameValue();
                nameValue.setName(
                        callbackFunId.getBytes(StandardCharsets.UTF_8));

                if (returnedObject != null) {
                    nameValue.setValues(
                            new byte[][] { returnedObject.buildBytes(true) });
                }

                push(invokeCallbackFuncTask, nameValue);
                if (holdPush.get() == 0) {
                    pushWffBMBytesQueue();
                }

            }

        } else {
            LOGGER.warning(methodName
                    + " doesn't exist, please add it as browserPage.addServerMethod(\""
                    + methodName + "\", serverAsyncMethod)");
        }

    }

    /**
     * executes the task in the given wff binary message. <br>
     * For WFF authors :- Make sure that the passing {@code message} is not
     * empty while consuming this method, just as made conditional checking in
     * {@code BrowserPage#webSocketMessaged(byte[])} method.
     *
     * @since 2.0.0
     * @author WFF
     * @throws UnsupportedEncodingException
     *                                          throwing this exception will be
     *                                          removed in future version
     *                                          because its internal
     *                                          implementation will never make
     *                                          this exception due to the code
     *                                          changes since 3.0.1.
     */
    private void executeWffBMTask(final byte[] message)
            throws UnsupportedEncodingException {

        final List<NameValue> nameValues = WffBinaryMessageUtil.VERSION_1
                .parse(message);

        final NameValue task = nameValues.get(0);
        final byte taskValue = task.getValues()[0][0];

        if (Task.TASK.getValueByte() == task.getName()[0]) {

            // IM stands for Invoke Method
            if (taskValue == Task.INVOKE_ASYNC_METHOD.getValueByte()) {

                invokeAsychMethod(nameValues);

            } else if (taskValue == Task.INVOKE_CUSTOM_SERVER_METHOD
                    .getValueByte()) {

                invokeCustomServerMethod(nameValues);

            } else if (taskValue == Task.REMOVE_BROWSER_PAGE.getValueByte()) {

                removeBrowserPageFromContext(nameValues);

            }

        }

    }

    private void addAttrValueChangeListener(final AbstractHtml abstractHtml) {

        if (valueChangeListener == null) {
            valueChangeListener = new AttributeValueChangeListenerImpl(this,
                    tagByWffId);
        }

        abstractHtml.getSharedObject()
                .setValueChangeListener(valueChangeListener, ACCESS_OBJECT);
    }

    private void addDataWffIdAttribute(final AbstractHtml abstractHtml) {

        final Deque<Set<AbstractHtml>> childrenStack = new ArrayDeque<>();
        // passed 2 instead of 1 because the load factor is 0.75f
        final Set<AbstractHtml> initialSet = new LinkedHashSet<>(2);
        initialSet.add(abstractHtml);
        childrenStack.push(initialSet);

        Set<AbstractHtml> children;

        while ((children = childrenStack.poll()) != null) {

            for (final AbstractHtml child : children) {

                if (child.getDataWffId() == null) {
                    child.setDataWffId(getNewDataWffId());
                }

                tagByWffId.put(child.getDataWffId().getValue(), child);

                final Set<AbstractHtml> subChildren = child
                        .getChildren(ACCESS_OBJECT);

                if (subChildren != null && subChildren.size() > 0) {
                    childrenStack.push(subChildren);
                }

            }

        }

    }

    private void embedWffScriptIfRequired(final AbstractHtml abstractHtml,
            final String wsUrlWithInstanceId) {

        if (wffScriptTagId != null
                && tagByWffId.containsKey(wffScriptTagId.getValue())) {
            // no need to add script tag if it exists in the ui
            return;
        }

        final Deque<Set<AbstractHtml>> childrenStack = new ArrayDeque<>();
        // passed 2 instead of 1 because the load factor is 0.75f
        final Set<AbstractHtml> initialSet = new LinkedHashSet<>(2);
        initialSet.add(abstractHtml);
        childrenStack.push(initialSet);

        boolean headAndbodyTagMissing = true;

        Set<AbstractHtml> children;

        outerLoop: while ((children = childrenStack.poll()) != null) {

            for (final AbstractHtml child : children) {

                if ((enableDeferOnWffScript
                        && TagNameConstants.HEAD.equals(child.getTagName()))
                        || (!enableDeferOnWffScript && TagNameConstants.BODY
                                .equals(child.getTagName()))) {

                    headAndbodyTagMissing = false;

                    wffScriptTagId = getNewDataWffId();

                    final Script script = new Script(null,
                            new Type(Type.TEXT_JAVASCRIPT));

                    script.setDataWffId(wffScriptTagId);

                    final String wffJs = WffJsFile.getAllOptimizedContent(
                            wsUrlWithInstanceId, getInstanceId(),
                            removePrevFromBrowserContextOnTabInit,
                            removeFromBrowserContextOnTabClose,
                            (wsHeartbeatInterval > 0 ? wsHeartbeatInterval
                                    : wsDefaultHeartbeatInterval),
                            (wsReconnectInterval > 0 ? wsReconnectInterval
                                    : wsDefaultReconnectInterval),
                            autoremoveWffScript);

                    if (enableDeferOnWffScript) {
                        // byes are in UTF-8 so charset=utf-8 is explicitly
                        // specified
                        // Defer must be first argument
                        script.addAttributes(new Defer(null), new Src(
                                "data:text/javascript;charset=utf-8;base64,"
                                        + HashUtil.base64FromUtf8Bytes(
                                                wffJs.getBytes(
                                                        StandardCharsets.UTF_8))));
                    } else {
                        new NoTag(script, wffJs);
                    }

                    if (nonceForWffScriptTag != null) {
                        script.addAttributes(nonceForWffScriptTag);
                    }

                    // to avoid invoking listener
                    child.addChild(ACCESS_OBJECT, script, false);

                    // ConcurrentHashMap cannot contain null as value
                    tagByWffId.put(wffScriptTagId.getValue(), script);

                    break outerLoop;
                }

                final Set<AbstractHtml> subChildren = child
                        .getChildren(ACCESS_OBJECT);

                if (subChildren != null && subChildren.size() > 0) {
                    childrenStack.push(subChildren);
                }
            }

        }

        if (headAndbodyTagMissing) {
            wffScriptTagId = getNewDataWffId();

            final Script script = new Script(null,
                    new Type(Type.TEXT_JAVASCRIPT));

            script.setDataWffId(wffScriptTagId);

            final String wffJs = WffJsFile.getAllOptimizedContent(
                    wsUrlWithInstanceId, getInstanceId(),
                    removePrevFromBrowserContextOnTabInit,
                    removeFromBrowserContextOnTabClose,
                    (wsHeartbeatInterval > 0 ? wsHeartbeatInterval
                            : wsDefaultHeartbeatInterval),
                    (wsReconnectInterval > 0 ? wsReconnectInterval
                            : wsDefaultReconnectInterval),
                    autoremoveWffScript);

            if (enableDeferOnWffScript) {
                // byes are in UTF-8 so charset=utf-8 is explicitly specified
                // Defer must be first argument
                script.addAttributes(new Defer(null),
                        new Src("data:text/javascript;charset=utf-8;base64,"
                                + HashUtil.base64FromUtf8Bytes(wffJs
                                        .getBytes(StandardCharsets.UTF_8))));
            } else {
                new NoTag(script, wffJs);
            }

            if (nonceForWffScriptTag != null) {
                script.addAttributes(nonceForWffScriptTag);
            }

            // to avoid invoking listener
            abstractHtml.addChild(ACCESS_OBJECT, script, false);

            // ConcurrentHashMap cannot contain null as value
            tagByWffId.put(wffScriptTagId.getValue(), script);

        }

    }

    private void addChildTagAppendListener(final AbstractHtml abstractHtml) {

        abstractHtml.getSharedObject().setChildTagAppendListener(
                new ChildTagAppendListenerImpl(this, ACCESS_OBJECT, tagByWffId),
                ACCESS_OBJECT);
    }

    private void addChildTagRemoveListener(final AbstractHtml abstractHtml) {

        abstractHtml.getSharedObject().setChildTagRemoveListener(
                new ChildTagRemoveListenerImpl(this, ACCESS_OBJECT, tagByWffId),
                ACCESS_OBJECT);
    }

    private void addAttributeAddListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setAttributeAddListener(
                new AttributeAddListenerImpl(this), ACCESS_OBJECT);
    }

    private void addAttributeRemoveListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setAttributeRemoveListener(
                new AttributeRemoveListenerImpl(this, tagByWffId),
                ACCESS_OBJECT);
    }

    private void addInnerHtmlAddListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setInnerHtmlAddListener(
                new InnerHtmlAddListenerImpl(this, ACCESS_OBJECT, tagByWffId),
                ACCESS_OBJECT);
    }

    private void addInsertBeforeListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setInsertBeforeListener(
                new InsertBeforeListenerImpl(this, ACCESS_OBJECT, tagByWffId),
                ACCESS_OBJECT);
    }

    private void addWffBMDataUpdateListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setWffBMDataUpdateListener(
                new WffBMDataUpdateListenerImpl(this), ACCESS_OBJECT);
    }

    private void addWffBMDataDeleteListener(final AbstractHtml abstractHtml) {
        abstractHtml.getSharedObject().setWffBMDataDeleteListener(
                new WffBMDataDeleteListenerImpl(this), ACCESS_OBJECT);
    }

    private void addPushQueue(final AbstractHtml rootTag) {
        rootTag.getSharedObject().setPushQueue(() -> {
            if (holdPush.get() == 0) {
                pushWffBMBytesQueue();
            }
        }, ACCESS_OBJECT);
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @return {@code String} equalent to the html string of the tag including
     *         the child tags.
     *
     * @author WFF
     */
    public String toHtmlString() {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final String htmlString = rootTag.toHtmlString(true);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return htmlString;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method. rebuilds the html string of the tag including the child
     * tags/values if parameter is true, otherwise returns the html string
     * prebuilt and kept in the cache.
     *
     * @param rebuild
     *                    true to rebuild & false to return previously built
     *                    string.
     * @return {@code String} equalent to the html string of the tag including
     *         the child tags.
     * @since 2.1.4
     * @author WFF
     */
    public String toHtmlString(final boolean rebuild) {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final String htmlString = rootTag.toHtmlString(rebuild);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return htmlString;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param charset
     *                    the charset
     * @return {@code String} equalent to the html string of the tag including
     *         the child tags.
     * @author WFF
     */
    public String toHtmlString(final String charset) {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final String htmlString = rootTag.toHtmlString(true, charset);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return htmlString;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method. rebuilds the html string of the tag including the child
     * tags/values if parameter is true, otherwise returns the html string
     * prebuilt and kept in the cache.
     *
     * @param rebuild
     *                    true to rebuild & false to return previously built
     *                    string.
     * @param charset
     *                    the charset to set for the returning value, eg:
     *                    {@code StandardCharsets.UTF_8.name()}
     * @return {@code String} equalent to the html string of the tag including
     *         the child tags.
     *
     * @since 2.1.4
     * @author WFF
     */
    public String toHtmlString(final boolean rebuild, final String charset) {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final String htmlString = rootTag.toHtmlString(rebuild, charset);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return htmlString;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *               the object of {@code OutputStream} to write to.
     * @return the total number of bytes written
     * @since 2.1.4 void toOutputStream
     * @since 2.1.8 int toOutputStream
     * @throws IOException
     */
    public int toOutputStream(final OutputStream os) throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, true);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                    the object of {@code OutputStream} to write to.
     * @param rebuild
     *                    true to rebuild & false to write previously built
     *                    bytes.
     * @return the total number of bytes written
     *
     * @throws IOException
     * @since 2.1.4 void toOutputStream
     * @since 2.1.8 int toOutputStream
     *
     */
    public int toOutputStream(final OutputStream os, final boolean rebuild)
            throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, rebuild);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                         the object of {@code OutputStream} to write to.
     * @param rebuild
     *                         true to rebuild & false to write previously
     *                         built bytes.
     * @param flushOnWrite
     *                         true to flush on each write to OutputStream
     * @return the total number of bytes written
     *
     * @throws IOException
     * @since 3.0.2
     *
     */
    public int toOutputStream(final OutputStream os, final boolean rebuild,
            final boolean flushOnWrite) throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, rebuild,
                flushOnWrite);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                    the object of {@code OutputStream} to write to.
     * @param charset
     *                    the charset
     * @return the total number of bytes written
     * @throws IOException
     * @since 2.1.4 void toOutputStream
     * @since 2.1.8 int toOutputStream
     */
    public int toOutputStream(final OutputStream os, final String charset)
            throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, true, charset);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                         the object of {@code OutputStream} to write to.
     * @param charset
     *                         the charset
     * @param flushOnWrite
     *                         true to flush on each write to OutputStream
     * @return the total number of bytes written
     * @throws IOException
     * @since 3.0.2
     */
    public int toOutputStream(final OutputStream os, final Charset charset,
            final boolean flushOnWrite) throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, true, charset,
                flushOnWrite);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                    the object of {@code OutputStream} to write to.
     * @param rebuild
     *                    true to rebuild & false to write previously built
     *                    bytes.
     * @param charset
     *                    the charset
     * @return the total number of bytes written
     * @throws IOException
     * @since 2.1.4 void toOutputStream
     * @since 2.1.8 int toOutputStream
     *
     */
    public int toOutputStream(final OutputStream os, final boolean rebuild,
            final String charset) throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, rebuild, charset);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    /**
     * NB: this method should not be called under {@link BrowserPage#render()}
     * method because this method internally calls {@link BrowserPage#render()}
     * method.
     *
     * @param os
     *                         the object of {@code OutputStream} to write to.
     * @param rebuild
     *                         true to rebuild & false to write previously
     *                         built bytes.
     * @param charset
     *                         the charset
     * @param flushOnWrite
     *                         true to flush on each write to OutputStream
     * @return the total number of bytes written
     * @throws IOException
     * @since 3.0.2
     *
     */
    public int toOutputStream(final OutputStream os, final boolean rebuild,
            final Charset charset, final boolean flushOnWrite)
            throws IOException {
        initAbstractHtml();
        wsWarningDisabled = true;
        beforeToHtml(rootTag);
        wsWarningDisabled = false;
        final int totalWritten = rootTag.toOutputStream(os, rebuild, charset,
                flushOnWrite);
        wsWarningDisabled = true;
        afterToHtml(rootTag);
        wsWarningDisabled = false;
        return totalWritten;
    }

    private void initAbstractHtml() {

        if (rootTag == null) {

            synchronized (this) {
                if (rootTag == null) {
                    if (renderInvoked) {
                        throw new InvalidUsageException(
                                "This method cannot be called here because this method is called by render or its child method.");
                    }
                    renderInvoked = true;
                    beforeRender();
                    rootTag = render();
                    if (rootTag == null) {
                        renderInvoked = false;
                        throw new NullValueException(
                                "render must return an instance of AbstractHtml, eg:- new Html(null);");
                    }

                    tagByWffId = rootTag.getSharedObject()
                            .initTagByWffId(ACCESS_OBJECT);

                    addDataWffIdAttribute(rootTag);
                    // attribute value change listener
                    // should be added only after adding data-wff-id attribute
                    addAttrValueChangeListener(rootTag);
                    addChildTagAppendListener(rootTag);
                    addChildTagRemoveListener(rootTag);
                    addAttributeAddListener(rootTag);
                    addAttributeRemoveListener(rootTag);
                    addInnerHtmlAddListener(rootTag);
                    addInsertBeforeListener(rootTag);
                    addWffBMDataUpdateListener(rootTag);
                    addWffBMDataDeleteListener(rootTag);
                    addPushQueue(rootTag);

                    wsWarningDisabled = true;
                    afterRender(rootTag);
                    wsWarningDisabled = false;
                } else {
                    synchronized (wffBMBytesQueue) {
                        wffBMBytesQueue.clear();
                        pushQueueSize.reset();
                    }
                }
            }

        } else {
            synchronized (wffBMBytesQueue) {
                wffBMBytesQueue.clear();
                pushQueueSize.reset();
            }
        }

        final String webSocketUrl = webSocketUrl();
        if (webSocketUrl == null) {
            throw new NullValueException(
                    "webSocketUrl must return valid WebSocket url");
        }

        final String wsUrlWithInstanceId = webSocketUrl.indexOf('?') == -1
                ? webSocketUrl + "?wffInstanceId=" + getInstanceId()
                : webSocketUrl + "&wffInstanceId=" + getInstanceId();

        embedWffScriptIfRequired(rootTag, wsUrlWithInstanceId);
    }

    /**
     * @return a unique id for this instance
     * @since 2.0.0
     * @author WFF
     */
    public final String getInstanceId() {
        return instanceId;
    }

    /**
     * By default, it is set as true.
     *
     * @return the pushQueueEnabled
     * @since 2.0.2
     */
    public final boolean isPushQueueEnabled() {
        return pushQueueEnabled;
    }

    /**
     * If the server could not push any server updates it will be put in the
     * queue and when it tries to push in the next time it will first push
     * updates from this queue. By default, it is set as true.
     *
     * @param enabledPushQueue
     *                             the enabledPushQueue to set
     * @since 2.0.2
     */
    public final void setPushQueueEnabled(final boolean enabledPushQueue) {
        pushQueueEnabled = enabledPushQueue;
    }

    /**
     * @param methodName
     * @param serverAsyncMethod
     * @since 2.1.0
     * @author WFF
     */
    public final void addServerMethod(final String methodName,
            final ServerAsyncMethod serverAsyncMethod) {
        serverMethods.put(methodName,
                new ServerMethod(serverAsyncMethod, null));
    }

    /**
     * @param methodName
     * @param serverAsyncMethod
     * @param serverSideData
     *                              this object will be available in the event
     *                              of serverAsyncMethod.asyncMethod
     * @since 3.0.2
     * @author WFF
     */
    public final void addServerMethod(final String methodName,
            final ServerAsyncMethod serverAsyncMethod,
            final Object serverSideData) {
        serverMethods.put(methodName,
                new ServerMethod(serverAsyncMethod, serverSideData));
    }

    /**
     * removes the method from
     *
     * @param methodName
     * @since 2.1.0
     * @author WFF
     */
    public final void removeServerMethod(final String methodName) {
        serverMethods.remove(methodName);
    }

    /**
     * performs action provided by {@code BrowserPageAction}.
     *
     * @param actionByteBuffer
     *                             The ByteBuffer object taken from
     *                             {@code BrowserPageAction} .Eg:-
     *                             {@code BrowserPageAction.RELOAD.getActionByteBuffer();}
     * @since 2.1.0
     * @author WFF
     */
    public final void performBrowserPageAction(
            final ByteBuffer actionByteBuffer) {
        push(actionByteBuffer);
        if (holdPush.get() == 0) {
            pushWffBMBytesQueue();
        }
    }

    /**
     * @return the pushQueueOnNewWebSocketListener true if it's enabled
     *         otherwise false. By default it's set as true.
     * @since 2.1.1
     */
    public final boolean isPushQueueOnNewWebSocketListener() {
        return pushQueueOnNewWebSocketListener;
    }

    /**
     * By default it's set as true. If it's enabled then the wffbmBytesQueue
     * will be pushed when new webSocket listener is added/set.
     *
     * @param pushQueueOnNewWebSocketListener
     *                                            the
     *                                            pushQueueOnNewWebSocketListener
     *                                            to set. Pass true to enable
     *                                            this option and false to
     *                                            disable this option.
     * @since 2.1.1
     */
    public final void setPushQueueOnNewWebSocketListener(
            final boolean pushQueueOnNewWebSocketListener) {
        this.pushQueueOnNewWebSocketListener = pushQueueOnNewWebSocketListener;
    }

    /**
     *
     * @return the holdPush true if the push is on hold
     * @since 2.1.3
     */
    public final boolean isHoldPush() {
        return holdPush.get() > 0;
    }

    /**
     * holds push if not already on hold until unholdPush is called Usage :-
     *
     * <pre>
     * try {
     *     browserPage.holdPush();
     *
     *     for (AbstractHtml tag : tags) {
     *         tag.removeAttributes("style");
     *     }
     *     // other tag manipulations
     * } finally {
     *     browserPage.unholdPush();
     * }
     * </pre>
     *
     * @since 2.1.3
     * @author WFF
     */
    public final void holdPush() {
        holdPush.incrementAndGet();
    }

    /**
     * unholds push if not already unheld. Usage :-
     *
     * <pre>
     * try {
     *     browserPage.holdPush();
     *
     *     for (AbstractHtml tag : tags) {
     *         tag.removeAttributes("style");
     *     }
     *     // other tag manipulations
     * } finally {
     *     browserPage.unholdPush();
     * }
     * </pre>
     *
     * @since 2.1.3
     * @author WFF
     */
    public final void unholdPush() {

        if (holdPush.get() > 0) {

            synchronized (wffBMBytesQueue) {

                holdPush.decrementAndGet();

                ByteBuffer wffBM = wffBMBytesHoldPushQueue.poll();

                if (wffBM != null) {

                    final NameValue invokeMultipleTasks = Task
                            .getTaskOfTasksNameValue();

                    final Deque<ByteBuffer> wffBMs = new ArrayDeque<>(
                            wffBMBytesHoldPushQueue.size() + 1);

                    do {

                        wffBMs.add(wffBM);

                        wffBM = wffBMBytesHoldPushQueue.poll();
                    } while (wffBM != null);

                    final byte[][] values = new byte[wffBMs.size()][0];

                    int index = 0;
                    for (final ByteBuffer eachWffBM : wffBMs) {
                        values[index] = eachWffBM.array();
                        index++;
                    }

                    invokeMultipleTasks.setValues(values);

                    wffBMBytesQueue
                            .add(ByteBuffer.wrap(WffBinaryMessageUtil.VERSION_1
                                    .getWffBinaryMessageBytes(
                                            invokeMultipleTasks)));
                    pushQueueSize.increment();

                    pushWffBMBytesQueue();
                }

            }
        }

    }

    /**
     * Gets the size of internal push queue. This size might not be accurate in
     * multi-threading environment.
     *
     * Use case :- Suppose there is a thread in the server which makes real time
     * ui changes. But if the end user lost connection and the webSocket is not
     * closed connection, in such case the developer can decide whether to make
     * any more ui updates from server when the pushQueueSize exceeds a
     * particular limit.
     *
     * @return the size of internal push queue.
     * @since 2.1.4
     * @author WFF
     */
    public final int getPushQueueSize() {
        // wffBMBytesQueue.size() is not reliable as
        // it's ConcurrentLinkedQueue.
        // As per the javadoc ConcurrentLinkedQueue.size is NOT a constant-time
        // operation. So to avoid performance degrade of using
        // wffBMBytesQueue.size a separate LongAdder is kept to maintain the
        // queue size.
        return pushQueueSize.intValue();
    }

    /**
     * By default On.TAB_CLOSE and On.INIT_REMOVE_PREVIOUS are enabled.
     *
     * @param enable
     * @param ons
     *                   the instance of On to represent on which browser event
     *                   the browser page needs to be removed.
     * @since 2.1.4
     * @author WFF
     */
    public final void removeFromContext(final boolean enable, final On... ons) {
        for (final On on : ons) {
            if (On.TAB_CLOSE.equals(on)) {
                removeFromBrowserContextOnTabClose = enable;
            } else if (On.INIT_REMOVE_PREVIOUS.equals(on)) {
                removePrevFromBrowserContextOnTabInit = enable;
            }
        }
    }

    /**
     * Invokes when this browser page instance is removed from browser page
     * context. Override and use this method to stop long running tasks /
     * threads.
     *
     * @since 2.1.4
     * @author WFF
     */
    protected void removedFromContext() {
        // NOP
        // To override and use this method
    }

    /**
     * To check if the given tag exists in the UI. <br>
     * NB:- This method is valid only if {@code browserPage#toHtmlString} or
     * {@code browserPage#toOutputStream} is called at least once in the life
     * time.
     *
     * @param tag
     *                the tag object to be checked.
     * @return true if the given tag contains in the BrowserPage i.e. UI. false
     *         if the given tag was removed or was not already added in the UI.
     * @throws NullValueException
     *                                  throws this exception if the given tag
     *                                  is null.
     * @throws NotRenderedException
     *                                  if the {@code BrowserPage} object is not
     *                                  rendered. i.e. if
     *                                  {@code browserPage#toHtmlString} or
     *                                  {@code browserPage#toOutputStream} was
     *                                  NOT called at least once in the life
     *                                  time.
     * @since 2.1.7
     * @author WFF
     */
    public final boolean contains(final AbstractHtml tag)
            throws NullValueException, NotRenderedException {

        if (tag == null) {
            throw new NullValueException(
                    "tag object in browserPage.contains(AbstractHtml tag) method cannot be null");
        }

        if (tagByWffId == null) {
            throw new NotRenderedException(
                    "Could not check its existance. Make sure that you have called browserPage#toHtmlString method atleast once in the life time.");
        }

        final DataWffId dataWffId = tag.getDataWffId();
        if (dataWffId == null) {
            return false;
        }
        return tag.equals(tagByWffId.get(dataWffId.getValue()));
    }

    /**
     * Sets the heartbeat ping interval of webSocket client in milliseconds.
     * Give -1 to disable it. By default it's set with -1. It affects only for
     * the corresponding {@code BrowserPage} instance from which it is called.
     * <br>
     * NB:- This method has effect only if it is called before
     * {@code BrowserPage#render()} method return. This method can be called
     * inside {@code BrowserPage#render()} method to override the default global
     * heartbeat interval set by
     * {@code BrowserPage#setWebSocketDefultHeartbeatInterval(int)} method.
     *
     * @param milliseconds
     *                         the heartbeat ping interval of webSocket client
     *                         in milliseconds. Give -1 to disable it.
     * @since 2.1.8
     * @author WFF
     */
    protected final void setWebSocketHeartbeatInterval(final int milliseconds) {
        wsHeartbeatInterval = milliseconds;
    }

    /**
     * @return the interval value set by
     *         {@code BrowserPage#setWebSocketHeartbeatInterval(int)} method.
     * @since 2.1.8
     * @author WFF
     */
    public final int getWebSocketHeartbeatInterval() {
        return wsHeartbeatInterval;
    }

    /**
     * Sets the default heartbeat ping interval of webSocket client in
     * milliseconds. Give -1 to disable it. It affects globally. By default it's
     * set with -1 till wffweb-2.1.8 and Since wffweb-2.1.9 it's 25000ms i.e. 25
     * seconds.<br>
     * NB:- This method has effect only if it is called before
     * {@code BrowserPage#render()} invocation.
     *
     *
     * @param milliseconds
     *                         the heartbeat ping interval of webSocket client
     *                         in milliseconds. Give -1 to disable it
     * @since 2.1.8
     * @since 2.1.9 the default value is 25000ms i.e. 25 seconds.
     * @author WFF
     */
    public static final void setWebSocketDefultHeartbeatInterval(
            final int milliseconds) {
        wsDefaultHeartbeatInterval = milliseconds;
    }

    /**
     * @return the interval value set by
     *         {@code setWebSocketDefultHeartbeatInterval} method.
     * @since 2.1.8
     * @author WFF
     */
    public static final int getWebSocketDefultHeartbeatInterval() {
        return wsDefaultHeartbeatInterval;
    }

    /**
     * Sets the default reconnect interval of webSocket client in milliseconds.
     * It affects globally. By default it's set with 2000 ms.<br>
     * NB:- This method has effect only if it is called before
     * {@code BrowserPage#render()} invocation.
     *
     *
     * @param milliseconds
     *                         the reconnect interval of webSocket client in
     *                         milliseconds. It must be greater than 0.
     * @since 2.1.8
     * @author WFF
     */
    public static final void setWebSocketDefultReconnectInterval(
            final int milliseconds) {
        if (milliseconds < 1) {
            throw new InvalidValueException("The value must be greater than 0");
        }
        wsDefaultReconnectInterval = milliseconds;
    }

    /**
     * @return the interval value set by
     *         {@code setWebSocketDefultReconnectInterval} method.
     * @since 2.1.8
     * @author WFF
     */
    public static final int getWebSocketDefultReconnectInterval() {
        return wsDefaultReconnectInterval;
    }

    /**
     * Sets the reconnect interval of webSocket client in milliseconds. Give -1
     * to disable it. By default it's set with -1. It affects only for the
     * corresponding {@code BrowserPage} instance from which it is called. <br>
     * NB:- This method has effect only if it is called before
     * {@code BrowserPage#render()} method return. This method can be called
     * inside {@code BrowserPage#render()} method to override the default global
     * WebSocket reconnect interval set by
     * {@code BrowserPage#setWebSocketDefultReconnectInterval(int)} method.
     *
     * @param milliseconds
     *                         the reconnect interval of webSocket client in
     *                         milliseconds. Give -1 to disable it.
     * @since 2.1.8
     * @author WFF
     */
    protected final void setWebSocketReconnectInterval(final int milliseconds) {
        wsReconnectInterval = milliseconds;
    }

    /**
     * @return the interval value set by
     *         {@code BrowserPage#setWebSocketReconnectInterval(int)} method.
     * @since 2.1.8
     * @author WFF
     */
    public final int getWebSocketReconnectInterval() {
        return wsReconnectInterval;
    }

    /**
     * Gets the TagRepository to do different tag operations. This tag
     * repository is specific to this BrowserPage instance.
     *
     * @return the TagRepository object to do different tag operations. Or null
     *         if any one of the BrowserPage#toString or
     *         BrowserPage#toOutputStream methods is not called.
     * @since 2.1.8
     * @author WFF
     */
    public final TagRepository getTagRepository() {

        if (tagRepository == null && rootTag != null) {
            synchronized (this) {
                if (tagRepository == null) {
                    tagRepository = new TagRepository(ACCESS_OBJECT, this,
                            tagByWffId, rootTag);
                }
            }
        }

        return tagRepository;
    }

    /**
     * Sets nonce attribute value for wff script.
     *
     * @param value
     *                  pass value to set nonce value or pass null to remove
     *                  nonce attribute
     * @since 3.0.1
     */
    protected final void setNonceForWffScript(final String value) {

        if (autoremoveWffScript) {
            throw new InvalidUsageException(
                    "Cannot remove while autoremoveWffScript is set as true. Please do setAutoremoveWffScript(false)");
        }

        if (value != null) {
            if (nonceForWffScriptTag == null) {
                nonceForWffScriptTag = new Nonce(value);
                if (wffScriptTagId != null) {
                    final AbstractHtml[] ownerTags = wffScriptTagId
                            .getOwnerTags();
                    if (ownerTags.length > 0) {
                        final AbstractHtml wffScript = ownerTags[0];
                        wffScript.addAttributes(nonceForWffScriptTag);
                    }
                }
            } else {
                nonceForWffScriptTag.setValue(value);
            }
        } else {
            if (wffScriptTagId != null && nonceForWffScriptTag != null) {
                final AbstractHtml[] ownerTags = wffScriptTagId.getOwnerTags();
                if (ownerTags.length > 0) {
                    final AbstractHtml wffScript = ownerTags[0];
                    wffScript.removeAttributes(nonceForWffScriptTag);
                }
            }
            nonceForWffScriptTag = null;
        }

    }

    /**
     * @param algo
     *                 eg: HashUtil.SHA_256
     * @return the base64 encoded string of the hash generated with the given
     *         algo for the wff script (text/javascript) content.
     * @since 3.0.1
     */
    private String getWffScriptHashInBase64(final String algo) {

        initAbstractHtml();

        if (wffScriptTagId != null) {
            final AbstractHtml[] ownerTags = wffScriptTagId.getOwnerTags();
            if (ownerTags.length > 0) {
                final AbstractHtml wffScript = ownerTags[0];
                final NoTag firstChild = (NoTag) wffScript.getFirstChild();
                final String childContent = firstChild.getChildContent();
                try {
                    return HashUtil.hashInBase64(childContent, algo);
                } catch (final NoSuchAlgorithmException e) {
                    if (LOGGER.isLoggable(Level.SEVERE)) {
                        LOGGER.log(Level.SEVERE,
                                "Make sure that the jdk supports " + algo, e);
                    }
                }
            }
        }

        return null;
    }

    /**
     * Generates and gets the SHA hash of internal wff script as base64 encoded
     * string. NB: this method should not be called under
     * {@link BrowserPage#render()} method because this method internally calls
     * {@link BrowserPage#render()} method.
     *
     * @return the wff script (text/javascript) content converted to SHA-256
     *         hash encoded in base64 string.
     * @since 3.0.1
     */
    public final String getWffScriptSHA256InBase64() {
        return getWffScriptHashInBase64(HashUtil.SHA_256);
    }

    /**
     * Generates and gets the SHA hash of internal wff script as base64 encoded
     * string. NB: this method should not be called under
     * {@link BrowserPage#render()} method because this method internally calls
     * {@link BrowserPage#render()} method.
     *
     * @return the wff script (text/javascript) content converted to SHA-384
     *         hash encoded in base64 string.
     * @since 3.0.1
     */
    public final String getWffScriptSHA384InBase64() {
        return getWffScriptHashInBase64(HashUtil.SHA_384);
    }

    /**
     * Generates and gets the SHA hash of internal wff script as base64 encoded
     * string. NB: this method should not be called under
     * {@link BrowserPage#render()} method because this method internally calls
     * {@link BrowserPage#render()} method.
     *
     * @return the wff script (text/javascript) content converted to SHA-512
     *         hash encoded in base64 string.
     * @since 3.0.1
     */
    public final String getWffScriptSHA512InBase64() {
        return getWffScriptHashInBase64(HashUtil.SHA_512);
    }

    /**
     * It must be called from render method to take effect. By default it's set
     * as true. so Content-Security-Policy is implemented then script-src must
     * allow data:.
     *
     * @param enable
     * @since 3.0.1
     */
    protected final void setEnableDeferOnWffScript(final boolean enable) {
        enableDeferOnWffScript = enable;
    }

    /**
     * by default it's true.
     *
     * @return true if enabled otherwise false
     * @since 3.0.1
     */
    protected final boolean isEnableDeferOnWffScript() {
        return enableDeferOnWffScript;
    }

    /**
     * By default it is true.
     *
     * @return the current state
     * @since 3.0.1
     */
    protected final boolean isAutoremoveWffScript() {
        return autoremoveWffScript;
    }

    /**
     * Automatically removes the wff script tag after loading it from the ui. By
     * default it is true.
     *
     * @param autoremoveWffScript
     * @since 3.0.1
     */
    protected final void setAutoremoveWffScript(
            final boolean autoremoveWffScript) {
        this.autoremoveWffScript = autoremoveWffScript;
    }

    /**
     * Gets the same instance of {@code PayloadProcessor} per caller thread for
     * this browser page. This PayloadProcessor can process incoming partial
     * bytes from WebSocket. To manually create new PayloadProcessor use <em>new
     * PayloadProcessor(browserPage)</em>.
     *
     * @return new instance of PayloadProcessor
     * @since 3.0.2
     */
    public final PayloadProcessor getPayloadProcessor() {
        return PALYLOAD_PROCESSOR_TL.get();
    }

    /**
     * Removes the current instance of {@code PayloadProcessor} of this caller
     * thread for this browser page and new instance will be reinitialized when
     * calling {@link #getPayloadProcessor()} by the same thread.
     *
     * @since 3.0.2
     */
    public final void removePayloadProcessor() {
        PALYLOAD_PROCESSOR_TL.remove();
    }

}


/*
 * Copyright 2014-2019 Web Firm Framework
 *
 * 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.
 * @author WFF
 */
package com.webfirmframework.wffweb.css;

import java.util.List;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.webfirmframework.wffweb.InvalidValueException;
import com.webfirmframework.wffweb.NullValueException;
import com.webfirmframework.wffweb.css.core.AbstractCssProperty;
import com.webfirmframework.wffweb.data.Bean;
import com.webfirmframework.wffweb.informer.StateChangeInformer;
import com.webfirmframework.wffweb.util.CssValueUtil;
import com.webfirmframework.wffweb.util.StringBuilderUtil;
import com.webfirmframework.wffweb.util.StringUtil;

/**
 *

 * border: border-width border-style border-color|initial|inherit;
 *
 * The border shorthand property sets all the border properties in one
 * declaration.
 *
 * The properties that can be set, are (in order): border-width, border-style,
 * and border-color.
 *
 * It does not matter if one of the values above are missing, e.g. border:solid
 * #ff0000; is allowed.
 *

 *
 * @author WFF
 * @since 1.0.0
 */
public class Border extends AbstractCssProperty<Border>
        implements StateChangeInformer<Bean> {

    private static final long serialVersionUID = 1_0_0L;

    public static final Logger LOGGER = Logger
            .getLogger(Border.class.getName());

    public static final String INITIAL = "initial";
    public static final String INHERIT = "inherit";

    private String cssValue;

    private String borderWidthValue;

    private BorderStyle borderStyle;

    private BorderColorCssValues borderColorCssValues;

    /**
     * The value medium none #000000 will be assigned as the
     * cssValue.
     *
     * @author WFF
     * @since 1.0.0
     */
    public Border() {
        setCssValue("medium none #000000");
    }

    /**
     * @param cssValue
     *                     the css value to set.
     */
    public Border(final String cssValue) {
        setCssValue(cssValue);
    }

    /**
     * @param border
     *                   the {@code border} object from which the cssValue to
     *                   set.And, {@code null} will throw
     *                   {@code NullValueException}
     */
    public Border(final Border border) {
        if (border == null) {
            throw new NullValueException("border can not be null");
        }
        setCssValue(border.getCssValue());
    }

    /*
     * (non-Javadoc)
     *
     * @see com.webfirmframework.wffweb.css.CssProperty#getCssName()
     *
     * @since 1.0.0
     *
     * @author WFF
     */
    @Override
    public String getCssName() {
        return CssNameConstants.BORDER;
    }

    /*
     * (non-Javadoc)
     *
     * @see com.webfirmframework.wffweb.css.CssProperty#getCssValue()
     *
     * @since 1.0.0
     *
     * @author WFF
     */
    @Override
    public String getCssValue() {
        return cssValue;
    }

    @Override
    public String toString() {
        return getCssName() + ": " + getCssValue();
    }

    /**
     * @param cssValue
     *                     the value should be in the format of
     *                     55px or 95%. {@code null}
     *                     is considered as an invalid value and it will throw
     *                     {@code NullValueException}.
     * @since 1.0.0
     * @author WFF
     */
    @Override
    public Border setCssValue(final String cssValue) {
        final String trimmedCssValue;
        if (cssValue == null) {
            throw new NullValueException(
                    "null is an invalid value. The value format should be as for example cjk-ideographic inside url(\"Sqpurple.gif\"). Or, initial/inherit.");
        } else if ((trimmedCssValue = StringUtil.strip(cssValue)).isEmpty()) {
            throw new NullValueException(cssValue
                    + " is an invalid value. The value format should be as for example cjk-ideographic inside url(\"Sqpurple.gif\"). Or, initial/inherit.");
        }

        final List<String> cssValueParts = CssValueUtil.split(trimmedCssValue);

        if (cssValueParts.size() > 1) {
            if (trimmedCssValue.contains(INITIAL)) {
                throw new InvalidValueException(
                        "The string 'initial' makes the given cssValue invalid, please remove 'initial' from it and try.");
            }
            if (trimmedCssValue.contains(INHERIT)) {
                throw new InvalidValueException(
                        "The string 'inherit' makes the given cssValue invalid, please remove 'inherit' from it and try.");
            }
        } else if (INITIAL.equals(trimmedCssValue)
                || INHERIT.equals(trimmedCssValue)) {
            this.cssValue = trimmedCssValue;
            if (borderColorCssValues != null) {
                borderColorCssValues.setAlreadyInUse(false);
                borderColorCssValues = null;
            }
            borderWidthValue = null;
            borderStyle = null;

            if (getStateChangeInformer() != null) {
                getStateChangeInformer().stateChanged(this);
            }

            return this;
        }

        String borderWidthValue = null;
        BorderStyle borderStyle = null;
        BorderColorCssValues borderColorCssValues = null;

        for (final String eachPart : cssValueParts) {
            if (borderWidthValue == null && BorderTopWidth.isValid(eachPart)) {
                borderWidthValue = new BorderTopWidth(eachPart).getCssValue();
            } else if (borderStyle == null && BorderStyle.isValid(eachPart)) {
                borderStyle = BorderStyle.getThis(eachPart);
            } else if (borderColorCssValues == null
                    && BorderColorCssValues.isValid(eachPart)) {
                if (this.borderColorCssValues == null) {
                    borderColorCssValues = new BorderColorCssValues(eachPart);
                    borderColorCssValues.setStateChangeInformer(this);
                } else {
                    this.borderColorCssValues.setValue(eachPart);
                    borderColorCssValues = this.borderColorCssValues;
                }
            }
        }

        final StringBuilder cssValueBuilder = new StringBuilder();
        boolean invalid = true;
        if (borderWidthValue != null) {
            cssValueBuilder.append(borderWidthValue).append(' ');
            invalid = false;
        }
        if (borderStyle != null) {
            cssValueBuilder.append(borderStyle.getCssValue()).append(' ');
            invalid = false;
        }
        if (borderColorCssValues != null) {
            cssValueBuilder.append(borderColorCssValues.getValue()).append(' ');
            invalid = false;
        }
        if (invalid) {
            throw new InvalidValueException(cssValue
                    + " is an invalid value. The value format should be as for example cjk-ideographic inside url(\"Sqpurple.gif\"). Or, initial/inherit.");
        }
        this.cssValue = StringBuilderUtil.getTrimmedString(cssValueBuilder);
        this.borderWidthValue = borderWidthValue;
        this.borderStyle = borderStyle;
        this.borderColorCssValues = borderColorCssValues;

        if (getStateChangeInformer() != null) {
            getStateChangeInformer().stateChanged(this);
        }

        return this;
    }

    /**
     * checks the given css value is valid for this class. It does't do a strict
     * validation.
     *
     * @param cssValue
     * @return
     * @since 1.0.0
     * @author WFF
     */
    public static boolean isValid(final String cssValue) {
        // TODO modify to make a strict validation
        final String trimmedCssValue;
        if (cssValue == null
                || (trimmedCssValue = StringUtil.strip(cssValue)).isEmpty()) {
            return false;
        }

        if (INITIAL.equals(trimmedCssValue)
                || INHERIT.equals(trimmedCssValue)) {
            return true;
        }

        final List<String> cssValueParts = CssValueUtil.split(trimmedCssValue);

        if (cssValueParts.size() > 1 && (trimmedCssValue.contains(INITIAL)
                || trimmedCssValue.contains(INHERIT))) {
            return false;
        }

        BorderTopWidth width = null;
        BorderStyle borderStyle = null;
        BorderColorCssValues borderColorCssValues = null;

        for (final String eachPart : cssValueParts) {
            boolean invalid = true;
            if (width == null && BorderTopWidth.isValid(eachPart)) {
                width = new BorderTopWidth(eachPart);
                invalid = false;
            } else if (borderStyle == null && BorderStyle.isValid(eachPart)) {
                borderStyle = BorderStyle.getThis(eachPart);
                invalid = false;
            } else if (borderColorCssValues == null
                    && BorderColorCssValues.isValid(eachPart)) {
                borderColorCssValues = new BorderColorCssValues(eachPart);
                invalid = false;
            }
            if (invalid) {
                return false;
            }
        }

        return true;
    }

    /**
     * sets as initial
     *
     * @since 1.0.0
     * @author WFF
     */
    public void setAsInitial() {
        setCssValue(INITIAL);
    }

    /**
     * sets as inherit
     *
     * @since 1.0.0
     * @author WFF
     */
    public void setAsInherit() {
        setCssValue(INHERIT);
    }

    /**
     * @return the {@code BorderStyle} object or null if there is not.
     * @author WFF
     * @since 1.0.0
     */
    public BorderColorCssValues getBorderColorCssValues() {
        return borderColorCssValues;
    }

    /**
     * @return the {@code BorderStyle} object or null if there is not.
     * @author WFF
     * @since 1.0.0
     */
    public BorderStyle getBorderStyle() {
        return borderStyle;
    }

    /**
     * @param borderWidthValue
     * @return the current object..
     * @author WFF
     * @since 1.0.0
     */
    public Border setBorderWidthValue(final String borderWidthValue) {

        final StringBuilder cssValueBuilder = new StringBuilder();

        if (borderWidthValue != null) {
            if (INITIAL.equals(borderWidthValue)
                    || INHERIT.equals(borderWidthValue)
                    || !BorderTopWidth.isValid(borderWidthValue)) {
                throw new InvalidValueException("The given value '"
                        + borderWidthValue
                        + "' is not valid, please try with a valid value like medium/thin/thick or 75px");
            }
            cssValueBuilder.append(borderWidthValue).append(' ');
        }
        if (borderStyle != null) {
            cssValueBuilder.append(borderStyle.getCssValue()).append(' ');
        }

        if (borderColorCssValues != null) {
            cssValueBuilder.append(borderColorCssValues.getValue()).append(' ');
        }

        final String trimmedCssValue = StringBuilderUtil
                .getTrimmedString(cssValueBuilder).toString();
        cssValue = trimmedCssValue.isEmpty() ? INHERIT : trimmedCssValue;

        this.borderWidthValue = borderWidthValue;

        if (getStateChangeInformer() != null) {
            getStateChangeInformer().stateChanged(this);
        }
        return this;
    }

    /**
     * to set the border style.
     *
     * @param borderStyle
     * @return the current object.
     * @author WFF
     * @since 1.0.0
     */
    public Border setBorderStyle(final BorderStyle borderStyle) {

        if (borderStyle == BorderStyle.INITIAL
                || borderStyle == BorderStyle.INHERIT) {
            throw new InvalidValueException("the given borderStyle cannot be "
                    + borderStyle.getCssValue());
        }

        final StringBuilder cssValueBuilder = new StringBuilder();

        if (borderWidthValue != null) {
            cssValueBuilder.append(borderWidthValue).append(' ');
        }

        if (borderStyle != null) {
            cssValueBuilder.append(borderStyle.getCssValue()).append(' ');
        }

        if (borderColorCssValues != null) {
            cssValueBuilder.append(borderColorCssValues.getValue()).append(' ');
        }

        final String trimmedCssValue = StringBuilderUtil
                .getTrimmedString(cssValueBuilder).toString();
        cssValue = trimmedCssValue.isEmpty() ? INHERIT : trimmedCssValue;

        this.borderStyle = borderStyle;

        if (getStateChangeInformer() != null) {
            getStateChangeInformer().stateChanged(this);
        }
        return this;
    }

    /**
     * @param borderColorCssValues
     *                                 to set the border color.
     * @return the current object
     * @author WFF
     * @since 1.0.0
     */
    public Border setBorderColorCssValues(
            final BorderColorCssValues borderColorCssValues) {

        final StringBuilder cssValueBuilder = new StringBuilder();

        if (borderWidthValue != null) {
            cssValueBuilder.append(borderWidthValue).append(' ');
        }

        if (borderStyle != null) {
            cssValueBuilder.append(borderStyle.getCssValue()).append(' ');
        }

        if (borderColorCssValues != null) {
            final String borderColorCssValuesCssValue = borderColorCssValues
                    .getValue();
            cssValueBuilder.append(borderColorCssValuesCssValue);
        }

        final String trimmedCssValue = StringBuilderUtil
                .getTrimmedString(cssValueBuilder).toString();
        cssValue = trimmedCssValue.isEmpty() ? INHERIT : trimmedCssValue;

        if (borderColorCssValues != null
                && borderColorCssValues.isAlreadyInUse()
                && !Objects.equals(this.borderColorCssValues,
                        borderColorCssValues)) {
            if (LOGGER.isLoggable(Level.WARNING)) {
                LOGGER.warning(
                        "the given borderColorCssValues is already used by another object so a new object or the previous object (if it exists) of BorderColorCssValues will be used");
            }
            return setCssValue(cssValue);
        }

        if (this.borderColorCssValues != null) {
            this.borderColorCssValues.setAlreadyInUse(false);
        }

        this.borderColorCssValues = borderColorCssValues;

        if (this.borderColorCssValues != null) {
            this.borderColorCssValues.setStateChangeInformer(this);
        }

        if (getStateChangeInformer() != null) {
            getStateChangeInformer().stateChanged(this);
        }
        return this;
    }

    /**
     * @return the borderWidthValue
     * @author WFF
     * @since 1.0.0
     */
    public String getBorderWidthValue() {
        return borderWidthValue;
    }

    @Override
    public void stateChanged(final Bean stateChangedObject) {
        if (stateChangedObject instanceof BorderColorCssValues) {
            setBorderColorCssValues((BorderColorCssValues) stateChangedObject);
        }
    }

}


/*
 * Copyright 2014-2019 Web Firm Framework
 *
 * 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.
 * @author WFF
 */
package com.webfirmframework.wffweb.css;

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.logging.Logger;

import com.webfirmframework.wffweb.InvalidValueException;
import com.webfirmframework.wffweb.NullValueException;
import com.webfirmframework.wffweb.css.core.AbstractCssProperty;
import com.webfirmframework.wffweb.util.StringBuilderUtil;
import com.webfirmframework.wffweb.util.StringUtil;
import com.webfirmframework.wffweb.util.TagStringUtil;

/**
 *

 * font-family: font|initial|inherit;
 *
 * Value:       {@code [[ <family-name> | <generic-family> ] [, <family-name>| <generic-family>]* ] | inherit}
 * Initial:        depends on user agent
 * Applies to:     all elements
 * Inherited:      yes
 * Percentages:    N/A
 * Media:          visual
 * Computed value:         as specified
 *
 * Support Classes :-
 * 1. GenericFontFamilyNameContants
 *

 *
 *
 * @author WFF
 * @version 1.0.0
 * @since 1.0.0
 */
public class FontFamily extends AbstractCssProperty<FontFamily> {

    private static final long serialVersionUID = 1_0_0L;

    public static final Logger LOGGER = Logger
            .getLogger(FontFamily.class.getName());

    private static final Set<String> FONT_FAMILY_NAMES = new HashSet<String>() {

        private static final long serialVersionUID = 1_0_0L;

        @Override
        public boolean add(final String value) {
            return super.add(TagStringUtil.toLowerCase(value));
        }

        @Override
        public boolean addAll(final Collection<? extends String> values) {
            boolean modified = false;
            for (final String each : values) {
                if (add(each)) {
                    modified = true;
                }
            }
            return modified;
        }

        @Override
        public boolean remove(final Object o) {
            return super.remove(TagStringUtil.toLowerCase(o.toString()));
        };

    };

    static {
        FONT_FAMILY_NAMES.add(GenericFontFamilyNameContants.CURSIVE);
        FONT_FAMILY_NAMES.add(GenericFontFamilyNameContants.FANTASY);
        FONT_FAMILY_NAMES.add(GenericFontFamilyNameContants.MONOSPACE);
        FONT_FAMILY_NAMES.add(GenericFontFamilyNameContants.SANS_SERIF);
        FONT_FAMILY_NAMES.add(GenericFontFamilyNameContants.SERIF);
    }

    private boolean validateFontFamilyName;
    private static boolean validateFontFamilyNameGlobally;

    public static final String INITIAL = "initial";
    public static final String INHERIT = "inherit";

    public static final String[] EMPTY_ARRAY = new String[0];

    private String cssValue;

    private String[] fontFamilyNames;

    /**
     * The {@code initial} will be set as the value
     */
    public FontFamily() {
        setCssValue(INITIAL);
    }

    /**
     * @param cssValue
     *                     the css value to set.
     */
    public FontFamily(final String cssValue) {
        setCssValue(cssValue);
    }

    /**
     *

     * Eg:-
     * String[] fontFamilyNames = {"Times New Roman", "Georgia", GenericFontFamilyNameContants.SERIF};
     *
     *

     *
     * @param fontFamilyNames
     *                            the font family names.
     */
    public FontFamily(final String... fontFamilyNames) {
        this.fontFamilyNames = StringUtil.cloneArray(fontFamilyNames);
        cssValue = getBuiltCssValue(this.fontFamilyNames);
    }

    /**
     * @param fontFamilyNames
     * @return the built cssValue string from the given fontFamilyNames.
     * @author WFF
     * @since 1.0.0
     */
    protected static String getBuiltCssValue(final String... fontFamilyNames) {
        final StringBuilder cssValueSB = new StringBuilder();
        int count = 1;
        for (final String fontFamilyName : fontFamilyNames) {
            if (StringUtil.containsSpace(fontFamilyName)) {
                cssValueSB.append('\"').append(fontFamilyName).append('\"');
            } else {
                cssValueSB.append(fontFamilyName);
            }

            if (count != fontFamilyNames.length) {
                cssValueSB.append(", ");
            } else {
                break;
            }
            count++;
        }
        return StringBuilderUtil.getTrimmedString(cssValueSB);
    }

    /**
     * @param fontFamily
     *                       the {@code FontFamily} object from which the
     *                       cssValue to set.And, {@code null} will throw
     *                       {@code NullValueException}
     */
    public FontFamily(final FontFamily fontFamily) {
        if (fontFamily == null) {
            throw new NullValueException("fontFamily can not be null");
        }
        setCssValue(fontFamily.getCssValue());
    }

    /*
     * (non-Javadoc)
     *
     * @see com.webfirmframework.wffweb.css.CssProperty#getCssName()
     *
     * @since 1.0.0
     *
     * @author WFF
     */
    @Override
    public String getCssName() {
        return CssNameConstants.FONT_FAMILY;
    }

    /*
     * (non-Javadoc)
     *
     * @see com.webfirmframework.wffweb.css.CssProperty#getCssValue()
     *
     * @since 1.0.0
     *
     * @author WFF
     */
    @Override
    public String getCssValue() {
        return cssValue;
    }

    @Override
    public String toString() {
        return getCssName() + ": " + getCssValue();
    }

    /**
     * @param cssValue
     *                     The value should be a fontFamilies sequence for
     *                     example \ "Times New Roman\", Georgia, Serif Or
     *                     initial/inherit.
     * @since 1.0.0
     * @author WFF
     */
    @Override
    public FontFamily setCssValue(final String cssValue) {
        if (cssValue == null) {
            throw new NullValueException(
                    "null is an invalid value. The value should be a fontFamilies sequence for example \"Times New Roman\", Georgia, Serif Or initial/inherit.");
        } else {
            final String trimmedCssValue = StringUtil.strip(cssValue);

            if (INITIAL.equals(cssValue) || INHERIT.equals(cssValue)) {
                fontFamilyNames = null;
            } else {
                fontFamilyNames = getExtractedFamilyNames(trimmedCssValue,
                        validateFontFamilyName
                                || validateFontFamilyNameGlobally);
            }
            this.cssValue = trimmedCssValue;

            if (getStateChangeInformer() != null) {
                getStateChangeInformer().stateChanged(this);
            }
        }
        return this;
    }

    /**
     * @param familyNames
     * @param validate
     *                        TODO
     * @return an array containing extracted family names from the given input.
     * @author WFF
     * @since 1.0.0
     */
    protected static String[] getExtractedFamilyNames(final String familyNames,
            final boolean validate) {
        final String[] parts = StringUtil.splitByComma(familyNames);

        int count = 0;

        for (final String each : parts) {
            final String trimmed = StringUtil.strip(each);

            final char firstChar;
            final char lastChar;

            if (trimmed.length() > 0) {
                firstChar = trimmed.charAt(0);
                lastChar = trimmed.charAt(trimmed.length() - 1);
            } else {
                firstChar = 0;
                lastChar = 0;
            }

            final boolean startsWith = firstChar == '"' || firstChar == '\'';
            int begin = 0;
            if (startsWith) {
                begin = 1;
            }

            final boolean endsWith = lastChar == '"' || lastChar == '\'';
            int end = trimmed.length();
            if (endsWith) {
                end = end - 1;
            }

            final String familyName = trimmed.substring(begin, end);

            if (validate && !FONT_FAMILY_NAMES
                    .contains(TagStringUtil.toLowerCase(familyName))) {
                throw new InvalidValueException("font-family name " + familyName
                        + " is not valid against the family names added by addFontFamilyName and addFontFamilyNames methods");
            }

            parts[count] = familyName;

            count++;
        }
        return parts;
    }

    /**
     *

     * Eg:-
     * String[] fontFamilyNames = {"Times New Roman", "Georgia", GenericFontFamilyNameContants.SERIF};
     *
     *

     *
     * @param fontFamilyNames
     *                            the fontFamilyNames to set
     * @author WFF
     * @since 1.0.0
     */
    public void setFontFamilyNames(final String[] fontFamilyNames) {
        if (fontFamilyNames != null) {
            this.fontFamilyNames = StringUtil.cloneArray(fontFamilyNames);
            cssValue = getBuiltCssValue(this.fontFamilyNames);
        } else {
            this.fontFamilyNames = null;
            cssValue = INITIAL;
        }
        if (getStateChangeInformer() != null) {
            getStateChangeInformer().stateChanged(this);
        }
    }

    /**
     * gets the font family names as an array and if here is no names set then
     * returns an empty array.
     *
     *

     * Note: it will never return null.
     *

     *
     * @return the fontFamilyNames. If there is no family name set then returns
     *         an empty array instead of null.
     * @author WFF
     * @since 1.0.0
     */
    public String[] getFontFamilyNames() {
        if (fontFamilyNames == null) {
            return EMPTY_ARRAY;
        }
        return StringUtil.cloneArray(fontFamilyNames);
    }

    /**
     * sets as {@code initial}
     *
     * @since 1.0.0
     * @author WFF
     */
    public void setAsInitial() {
        setCssValue(INITIAL);
    }

    /**
     * sets as {@code inherit}
     *
     * @since 1.0.0
     * @author WFF
     */
    public void setAsInherit() {
        setCssValue(INHERIT);
    }

    /**
     *
     * @param fontFamilyName
     *                           the supported font-family.
     * @author WFF
     * @since 1.0.0
     */
    public static void addFontFamilyName(final String fontFamilyName) {
        FONT_FAMILY_NAMES.add(fontFamilyName);
    }

    /**
     * @param fontFamilyNames
     *                            the supported font-family names.
     * @author WFF
     * @since 1.0.0
     */
    public static void addFontFamilyNames(
            final Collection<String> fontFamilyNames) {
        FONT_FAMILY_NAMES.addAll(fontFamilyNames);
    }

    /**
     *
     * @author WFF
     * @return the set of font-family names added by
     *         {@code FontFamily#addFontFamilyName(String)} and
     *         {@code FontFamily#addFontFamilyNames(Collection)} methods.
     * @since 1.0.0
     */
    public Set<String> getAddedFontFamilyNames() {
        return FONT_FAMILY_NAMES;
    }

    /**
     * @return the validateFontFamilyName
     * @author WFF
     * @since 1.0.0
     */
    public boolean isValidateFontFamilyName() {
        return validateFontFamilyName;
    }

    /**
     * @param validateFontFamilyName
     *                                   the validateFontFamilyName to set
     * @author WFF
     * @since 1.0.0
     */
    public void setValidateFontFamilyName(
            final boolean validateFontFamilyName) {
        this.validateFontFamilyName = validateFontFamilyName;
    }

    /**
     * @return the validateFontFamilyNameGlobally
     * @author WFF
     * @since 1.0.0
     */
    public static boolean isValidateFontFamilyNameGlobally() {
        return validateFontFamilyNameGlobally;
    }

    /**
     * @param validateFontFamilyNameGlobally
     *                                           the
     *                                           validateFontFamilyNameGlobally
     *                                           to set
     * @author WFF
     * @since 1.0.0
     */
    public static void setValidateFontFamilyNameGlobally(
            final boolean validateFontFamilyNameGlobally) {
        FontFamily.validateFontFamilyNameGlobally = validateFontFamilyNameGlobally;
    }

    /**
     * @return true if the given cssValue is validate. It also checks whether
     *         the font-family names added by
     *         {@code FontFamily#addFontFamilyName(String)} and
     *         {@code FontFamily#addFontFamilyNames(Collection)} methods contain
     *         the given cssValue.
     * @author WFF
     * @since 1.0.0
     */
    public static boolean isValid(final String cssValue) {
        final String cssValueLC = TagStringUtil
                .toLowerCase(StringUtil.strip(cssValue));
        return INITIAL.equals(cssValueLC) || INHERIT.equals(cssValueLC)
                || FONT_FAMILY_NAMES.contains(cssValueLC);
    }
}