/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.wicket.session;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.CopyOnWriteArraySet;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionBindingEvent;
import jakarta.servlet.http.HttpSessionBindingListener;

import org.apache.wicket.Application;
import org.apache.wicket.Session;
import org.apache.wicket.markup.MarkupParser;
import org.apache.wicket.protocol.http.IRequestLogger;
import org.apache.wicket.protocol.http.WebApplication;
import org.apache.wicket.request.Request;
import org.apache.wicket.request.http.WebRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Implementation of {@link ISessionStore} that works with web applications and provides some
 * specific http servlet/ session related functionality.
 * 
 * @author jcompagner
 * @author Eelco Hillenius
 * @author Matej Knopp
 */
public class HttpSessionStore implements ISessionStore
{
	private static final Logger log = LoggerFactory.getLogger(HttpSessionStore.class);

	private final Set<UnboundListener> unboundListeners = new CopyOnWriteArraySet<>();

	private final Set<BindListener> bindListeners = new CopyOnWriteArraySet<>();

	/**
	 * @param request The Wicket request
	 * @return The http servlet request
	 */
	protected final HttpServletRequest getHttpServletRequest(final Request request)
	{
		Object containerRequest = request.getContainerRequest();
		if ((containerRequest instanceof HttpServletRequest) == false)
		{
			throw new IllegalArgumentException("Request must be ServletWebRequest");
		}
		return (HttpServletRequest)containerRequest;
	}

	/**
	 * 
	 * @see HttpServletRequest#getSession(boolean)
	 * 
	 * @param request
	 *            A Wicket request object
	 * @param create
	 *            If true, a session will be created if it is not existing yet
	 * @return The HttpSession associated with this request or null if {@code create} is false and
	 *         the {@code request} has no valid session
	 */
	final HttpSession getHttpSession(final Request request, final boolean create)
	{
		return getHttpServletRequest(request).getSession(create);
	}

	@Override
	public final void bind(final Request request, final Session newSession)
	{
		if (getWicketSession(request) != newSession)
		{
			// call template method
			onBind(request, newSession);
			for (BindListener listener : getBindListeners())
			{
				listener.bindingSession(request, newSession);
			}

			HttpSession httpSession = getHttpSession(request, false);

			if (httpSession != null)
			{
				// register an unbinding listener for cleaning up
				String applicationKey = Application.get().getName();
				withSession(httpSession.getId(), () -> {
					httpSession.setAttribute("Wicket:SessionUnbindingListener-" + applicationKey,
							new SessionBindingListener(applicationKey, newSession));

					// register the session object itself
					setWicketSession(request, newSession);
					return null;
				});
			}
		}
	}

	@Override
	public void flushSession(Request request, Session session)
	{
		if (getWicketSession(request) != session)
		{
			// this session is not yet bound, bind it
			bind(request, session);
		}
		else
		{
			setWicketSession(request, session);
		}
	}

	@Override
	public void destroy()
	{
	}

	@Override
	public String getSessionId(final Request request, final boolean create)
	{
		String id = null;

		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			id = httpSession.getId();
		}
		else if (create)
		{
			httpSession = getHttpSession(request, true);
			id = httpSession.getId();

			IRequestLogger logger = Application.get().getRequestLogger();
			if (logger != null)
			{
				logger.sessionCreated(id);
			}
		}
		return id;
	}

	@Override
	public final void invalidate(final Request request)
	{
		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			withSession(httpSession.getId(), () -> {
				httpSession.invalidate();
				return null;
			});
		}
	}

	@Override
	public final Session lookup(final Request request)
	{
		String sessionId = getSessionId(request, false);
		if (sessionId != null)
		{
			return getWicketSession(request);
		}
		return null;
	}

	/**
	 * Reads the Wicket {@link Session} from the {@link HttpSession}'s attribute
	 *
	 * @param request The Wicket request
	 * @return The Wicket Session or {@code null}
	 */
	protected Session getWicketSession(final Request request)
	{
		return (Session) getAttribute(request, Session.SESSION_ATTRIBUTE_NAME);
	}

	/**
	 * Stores the Wicket {@link Session} in an attribute in the {@link HttpSession}
	 *
	 * @param request The Wicket request
	 * @param session The Wicket session
	 */
	protected void setWicketSession(final Request request, final Session session)
	{
		setAttribute(request, Session.SESSION_ATTRIBUTE_NAME, session);
	}

	/**
	 * Template method that is called when a session is being bound to the session store. It is
	 * called <strong>before</strong> the session object itself is added to this store (which is
	 * done by calling {@link ISessionStore#setAttribute(Request, String, Serializable)} with key
	 * {@link Session#SESSION_ATTRIBUTE_NAME}.
	 * 
	 * @param request
	 *            The request
	 * @param newSession
	 *            The new session
	 */
	protected void onBind(final Request request, final Session newSession)
	{
	}

	/**
	 * Template method that is called when the session is being detached from the store, which
	 * typically happens when the {@link HttpSession} was invalidated.
	 * 
	 * @param sessionId
	 *            The session id of the session that was invalidated.
	 */
	protected void onUnbind(final String sessionId)
	{
	}

	/**
	 * Gets the prefix for storing variables in the actual session (typically {@link HttpSession})
	 * for this application instance.
	 * 
	 * @param request
	 *            the request
	 * 
	 * @return the prefix for storing variables in the actual session
	 */
	private String getSessionAttributePrefix(final Request request)
	{
		String sessionAttributePrefix = MarkupParser.WICKET;

		if (request instanceof WebRequest)
		{
			sessionAttributePrefix = WebApplication.get().getSessionAttributePrefix(
				(WebRequest)request, null);
		}

		return sessionAttributePrefix;
	}

	@Override
	public final Serializable getAttribute(final Request request, final String name)
	{
		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			return withSession(httpSession.getId(), () -> {
				return (Serializable)httpSession
					.getAttribute(getSessionAttributePrefix(request) + name);
			});
		}
		return null;
	}

	@Override
	public final List<String> getAttributeNames(final Request request)
	{
		List<String> list = new ArrayList<>();
		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			withSession(httpSession.getId(), () -> {
				final Enumeration<String> names = httpSession.getAttributeNames();
				final String prefix = getSessionAttributePrefix(request);
				while (names.hasMoreElements())
				{
					final String name = names.nextElement();
					if (name.startsWith(prefix))
					{
						list.add(name.substring(prefix.length()));
					}
				}
				return null;
			});
		}
		return list;
	}

	@Override
	public final void removeAttribute(final Request request, final String name)
	{
		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			String attributeName = getSessionAttributePrefix(request) + name;

			IRequestLogger logger = Application.get().getRequestLogger();
			withSession(httpSession.getId(), () -> {
				if (logger != null)
				{
					Object value = httpSession.getAttribute(attributeName);
					if (value != null)
					{
						logger.objectRemoved(value);
					}
				}
				httpSession.removeAttribute(attributeName);
				return null;
			});
		}
	}

	@Override
	public final void setAttribute(final Request request, final String name,
		final Serializable value)
	{
		// ignore call if the session was marked invalid
		HttpSession httpSession = getHttpSession(request, false);
		if (httpSession != null)
		{
			String attributeName = getSessionAttributePrefix(request) + name;
			IRequestLogger logger = Application.get().getRequestLogger();
			withSession(httpSession.getId(), () -> {
				if (logger != null)
				{
					if (httpSession.getAttribute(attributeName) == null)
					{
						logger.objectCreated(value);
					}
					else
					{
						logger.objectUpdated(value);
					}
				}
				httpSession.setAttribute(attributeName, value);
				return null;
			});
		}
	}

	@Override
	public final void registerUnboundListener(final UnboundListener listener)
	{
		unboundListeners.add(listener);
	}

	@Override
	public final void unregisterUnboundListener(final UnboundListener listener)
	{
		unboundListeners.remove(listener);
	}

	@Override
	public final Set<UnboundListener> getUnboundListener()
	{
		return Collections.unmodifiableSet(unboundListeners);
	}

	/**
	 * Registers listener invoked when session is bound.
	 * 
	 * @param listener
	 */
	@Override
	public void registerBindListener(BindListener listener)
	{
		bindListeners.add(listener);
	}

	/**
	 * Unregisters listener invoked when session is bound.
	 * 
	 * @param listener
	 */
	@Override
	public void unregisterBindListener(BindListener listener)
	{
		bindListeners.remove(listener);
	}

	/**
	 * @return The list of registered bind listeners
	 */
	@Override
	public Set<BindListener> getBindListeners()
	{
		return Collections.unmodifiableSet(bindListeners);
	}

	/**
	 * Reacts on unbinding from the session by cleaning up the session related data.
	 */
	protected static final class SessionBindingListener
		implements
			HttpSessionBindingListener,
			Serializable
	{
		private static final long serialVersionUID = 1L;

		/** The unique key of the application within this web application. */
		private final String applicationKey;

		/**
 		 * The Wicket Session associated with the expiring HttpSession
 		 */
		private final Session wicketSession;

		/**
		 * Construct.
		 * 
		 * @param applicationKey
		 *            The unique key of the application within this web application
		 * @param wicketSession
		 *            The Wicket Session associated with the expiring http session
		 */
		public SessionBindingListener(final String applicationKey, final Session wicketSession)
		{
			this.applicationKey = applicationKey;
			this.wicketSession = wicketSession;
		}

		@Override
		public void valueBound(final HttpSessionBindingEvent evg)
		{
		}

		@Override
		public void valueUnbound(final HttpSessionBindingEvent evt)
		{
			String sessionId = evt.getSession().getId();

			log.debug("Session unbound: {}", sessionId);

			if (wicketSession != null)
			{
				wicketSession.onInvalidate();
			}
			
			Application application = Application.get(applicationKey);
			if (application == null)
			{
				log.debug("Wicket application with name '{}' not found.", applicationKey);
				return;
			}

			ISessionStore sessionStore = application.getSessionStore();
			if (sessionStore != null)
			{
				if (sessionStore instanceof HttpSessionStore)
				{
					((HttpSessionStore) sessionStore).onUnbind(sessionId);
				}

				for (UnboundListener listener : sessionStore.getUnboundListener())
				{
					listener.sessionUnbound(sessionId);
				}
			}
		}
	}

	/**
	 * @param sessionId The id of the HTTP session that might happen to be invalidated 
	 *                  in the meantime
	 * @return prefix for session attributes
	 */
	private <V> V withSession(String sessionId, Callable<V> callable) {
		try
		{
			return callable.call();
		}
		catch (IllegalStateException isx)
		{
			log.debug("HTTP session {} is no more valid!", sessionId, isx);
		} catch (Exception e) {
			throw new RuntimeException(e);
		}
		return null;
	}
}
