<commit_before>/*
 * JGrapes Event Driven Framework
 * Copyright (C) 2017  <NAME>
 * 
 * This program is free software; you can redistribute it and/or modify it 
 * under the terms of the GNU General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or 
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 
 * for more details.
 * 
 * You should have received a copy of the GNU General Public License along 
 * with this program; if not, see <http://www.gnu.org/licenses/>.
 */

package org.jgrapes.portal;

import freemarker.template.Configuration;
import freemarker.template.SimpleScalar;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PipedReader;
import java.io.PipedWriter;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.CharBuffer;
import java.security.Principal;
import java.text.Collator;
import java.text.ParseException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.StreamSupport;

import javax.json.Json;
import javax.json.JsonReader;

import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
import org.jdrupes.httpcodec.protocols.http.HttpField;
import org.jdrupes.httpcodec.protocols.http.HttpRequest;
import org.jdrupes.httpcodec.protocols.http.HttpResponse;
import org.jdrupes.httpcodec.types.Converters;
import org.jdrupes.httpcodec.types.Directive;
import org.jdrupes.httpcodec.types.MediaType;
import org.jdrupes.json.JsonDecodeException;
import org.jgrapes.core.Channel;
import org.jgrapes.core.CompletionLock;
import org.jgrapes.core.Component;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.Error;
import org.jgrapes.http.LanguageSelector.Selection;
import org.jgrapes.http.Session;
import org.jgrapes.http.annotation.RequestHandler;
import org.jgrapes.http.events.GetRequest;
import org.jgrapes.http.events.Response;
import org.jgrapes.http.events.WebSocketAccepted;
import org.jgrapes.io.IOSubchannel;
import org.jgrapes.io.events.Closed;
import org.jgrapes.io.events.Input;
import org.jgrapes.io.events.Output;
import org.jgrapes.io.util.ByteBufferOutputStream;
import org.jgrapes.io.util.CharBufferWriter;
import org.jgrapes.io.util.InputStreamPipeline;
import org.jgrapes.io.util.LinkedIOSubchannel;
import org.jgrapes.io.util.ManagedCharBuffer;
import org.jgrapes.portal.events.JsonInput;
import org.jgrapes.portal.events.JsonOutput;
import org.jgrapes.portal.events.PortalReady;
import org.jgrapes.portal.events.PortletResourceRequest;
import org.jgrapes.portal.events.PortletResourceResponse;
import org.jgrapes.portal.events.SetLocale;
import org.jgrapes.portal.events.SetTheme;
import org.jgrapes.portal.themes.base.Provider;
import org.jgrapes.util.events.KeyValueStoreData;
import org.jgrapes.util.events.KeyValueStoreQuery;
import org.jgrapes.util.events.KeyValueStoreUpdate;

/**
 * 
 */
public class PortalView extends Component {

	private Portal portal;
	private ServiceLoader<ThemeProvider> themeLoader;
	private static Configuration fmConfig = null;
	
	private Function<Locale,ResourceBundle> resourceBundleSupplier;
	private BiFunction<ThemeProvider,String,InputStream> fallbackResourceSupplier
		= (themeProvider, resource) -> { return null; };
	private Set<Locale> supportedLocales;
	
	private ThemeProvider baseTheme;
	private Map<String,Object> portalBaseModel = new HashMap<>();
	private RenderSupport renderSupport = new RenderSupportImpl();

	/**
	 * @param componentChannel
	 */
	public PortalView(Portal portal, Channel componentChannel) {
		super(componentChannel);
		this.portal = portal;
		if (fmConfig == null) {
			fmConfig = new Configuration(Configuration.VERSION_2_3_26);
			fmConfig.setClassLoaderForTemplateLoading(
					getClass().getClassLoader(), "org/jgrapes/portal");
			fmConfig.setDefaultEncoding("utf-8");
			fmConfig.setTemplateExceptionHandler(
					TemplateExceptionHandler.RETHROW_HANDLER);
	        fmConfig.setLogTemplateExceptions(false);
		}
		baseTheme = new Provider();
		
		supportedLocales = new HashSet<>();
		for (Locale locale: Locale.getAvailableLocales()) {
			if (locale.getLanguage().equals("")) {
				continue;
			}
			if (resourceBundleSupplier != null) {
				ResourceBundle rb = resourceBundleSupplier.apply(locale);
				if (rb.getLocale().equals(locale)) {
					supportedLocales.add(locale);
				}
			}
			ResourceBundle rb = ResourceBundle.getBundle(getClass()
					.getPackage().getName()	+ ".l10n", locale);
			if (rb.getLocale().equals(locale)) {
				supportedLocales.add(locale);
			}
		}
		
		RequestHandler.Evaluator.add(this, "onGet",	portal.prefix() + "**");
		RequestHandler.Evaluator.add(this, "onGetRedirect",
				portal.prefix().getPath().substring(
						0, portal.prefix().getPath().length() - 1));
		
		// Create portal model
		portalBaseModel.put("resourceUrl", new TemplateMethodModelEx() {
			@Override
			public Object exec(@SuppressWarnings("rawtypes") List arguments)
					throws TemplateModelException {
				@SuppressWarnings("unchecked")
				List<TemplateModel> args = (List<TemplateModel>)arguments;
				if (!(args.get(0) instanceof SimpleScalar)) {
					throw new TemplateModelException("Not a string.");
				}
				return portal.prefix().resolve(
						((SimpleScalar)args.get(0)).getAsString()).getRawPath();
			}
		});
		
		portalBaseModel = Collections.unmodifiableMap(portalBaseModel);

		// Handlers attached to the portal side channel
		Handler.Evaluator.add(this, "onPortalReady", portal.channel());
		Handler.Evaluator.add(this, "onKeyValueStoreData", portal.channel());
		Handler.Evaluator.add(
				this, "onPortletResourceResponse", portal.channel());
		Handler.Evaluator.add(this, "onJsonOutput", portal.channel());
		Handler.Evaluator.add(this, "onSetLocale", portal.channel());
		Handler.Evaluator.add(this, "onSetTheme", portal.channel());
	}

	/**
	 * The service loader must be created lazily, else the OSGi
	 * service mediator doesn't work properly.
	 * 
	 * @return
	 */
	private ServiceLoader<ThemeProvider> themeLoader() {
		if (themeLoader != null) {
			return themeLoader;
		}
		return themeLoader = ServiceLoader.load(ThemeProvider.class);
	}
	
	void setResourceBundleSupplier(
			Function<Locale,ResourceBundle> supplier) {
		this.resourceBundleSupplier = supplier;
	}
	
	void setFallbackResourceSupplier(
			BiFunction<ThemeProvider,String,InputStream> supplier) {
		this.fallbackResourceSupplier = supplier;
	}
	
	RenderSupport renderSupport() {
		return renderSupport;
	}
	
	private LinkedIOSubchannel portalChannel(IOSubchannel channel) {
		@SuppressWarnings("unchecked")
		Optional<LinkedIOSubchannel> portalChannel
			= (Optional<LinkedIOSubchannel>)LinkedIOSubchannel
				.downstreamChannel(portal, channel);
		return portalChannel.orElseGet(
				() -> new LinkedIOSubchannel(portal, channel));
	}

	@RequestHandler(dynamic=true)
	public void onGetRedirect(GetRequest event, IOSubchannel channel) 
			throws InterruptedException, IOException, ParseException {
		HttpResponse response = event.httpRequest().response().get();
		response.setStatus(HttpStatus.MOVED_PERMANENTLY)
			.setContentType("text", "plain", "utf-8")
			.setField(HttpField.LOCATION, portal.prefix());
		fire(new Response(response), channel);
		try {
			fire(Output.wrap(portal.prefix().toString()
					.getBytes("utf-8"), true), channel);
		} catch (UnsupportedEncodingException e) {
			// Supported by definition
		}
		event.stop();
	}
	
	@RequestHandler(dynamic=true)
	public void onGet(GetRequest event, IOSubchannel channel) 
			throws InterruptedException, IOException {
		URI requestUri = event.requestUri();
		// Append trailing slash, if missing
		if ((requestUri.getRawPath() + "/").equals(
				portal.prefix().getRawPath())) {
			requestUri = portal.prefix();
		}
		
		// Request for portal?
		if (!requestUri.getRawPath().startsWith(portal.prefix().getRawPath())) {
			return;
		}
		
		// Normalize and evaluate
		requestUri = portal.prefix().relativize(
				URI.create(requestUri.getRawPath()));
		if (requestUri.getRawPath().isEmpty()) {
			if (event.httpRequest().findField(
					HttpField.UPGRADE, Converters.STRING_LIST)
					.map(f -> f.value().containsIgnoreCase("websocket"))
					.orElse(false)) {
				channel.setAssociated(this, new PortalInfo());
				channel.respond(new WebSocketAccepted(event));
				event.stop();
				return;
			}
			
			renderPortal(event, channel);
			return;
		}
		URI subUri = uriFromPath("portal-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			sendPortalResource(event, channel, subUri.getPath());
			return;
		}
		subUri = uriFromPath("theme-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			sendThemeResource(event, channel, subUri.getPath());
			return;
		}
		subUri = uriFromPath("portlet-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			requestPortletResource(event, channel, subUri);
			return;
		}
	}

	private void renderPortal(GetRequest event, IOSubchannel channel)
		throws IOException, InterruptedException {
		event.stop();
		
		// Because language is changed via websocket, locale cookie 
		// may be out-dated
		event.associated(Selection.class)
			.ifPresent(s ->	s.prefer(s.get()[0]));
		
		// Prepare response
		HttpResponse response = event.httpRequest().response().get();
		MediaType mediaType = MediaType.builder().setType("text", "html")
				.setParameter("charset", "utf-8").build();
		response.setField(HttpField.CONTENT_TYPE, mediaType);
		response.setStatus(HttpStatus.OK);
		response.setHasPayload(true);
		channel.respond(new Response(response));
		try (Writer out = new OutputStreamWriter(new ByteBufferOutputStream(
				channel, channel.responsePipeline()), "utf-8")) {
			Map<String,Object> portalModel = new HashMap<>(portalBaseModel);

			// Add locale
			final Locale locale = event.associated(Selection.class).map(
					s -> s.get()[0]).orElse(Locale.getDefault());
			portalModel.put("locale", locale);

			// Add supported locales
			final Collator coll = Collator.getInstance(locale);
			final Comparator<LanguageInfo> comp 
				= new Comparator<PortalView.LanguageInfo>() {
				@Override
				public int compare(LanguageInfo o1,  LanguageInfo o2) {
					return coll.compare(o1.getLabel(), o2.getLabel());
				}
			};
			LanguageInfo[] languages = supportedLocales.stream()
					.map(l -> new LanguageInfo(l))
					.sorted(comp).toArray(size -> new LanguageInfo[size]);
			portalModel.put("supportedLanguages", languages);

			// Add localization
			final ResourceBundle additionalResources = resourceBundleSupplier == null
					? null : resourceBundleSupplier.apply(locale);
			final ResourceBundle baseResources = ResourceBundle.getBundle(
					getClass().getPackage().getName() + ".l10n", locale,
					ResourceBundle.Control.getNoFallbackControl(
							ResourceBundle.Control.FORMAT_DEFAULT));
			portalModel.put("_", new TemplateMethodModelEx() {
				@Override
				public Object exec(@SuppressWarnings("rawtypes") List arguments)
						throws TemplateModelException {
					@SuppressWarnings("unchecked")
					List<TemplateModel> args = (List<TemplateModel>)arguments;
					if (!(args.get(0) instanceof SimpleScalar)) {
						throw new TemplateModelException("Not a string.");
					}
					String key = ((SimpleScalar)args.get(0)).getAsString();
					try {
						return additionalResources.getString(key);
					} catch (MissingResourceException e) {
						// try base resources
					}
					try {
						return baseResources.getString(key);
					} catch (MissingResourceException e) {
						// no luck
					}
					return key;
				}
			});

			// Add themes. Doing this on every reload allows themes
			// to be added dynamically. Note that we must load again
			// (not reload) in order for this to work in an OSGi environment.
			themeLoader = ServiceLoader.load(ThemeProvider.class);
			portalModel.put("themeInfos", 
					StreamSupport.stream(themeLoader().spliterator(), false)
					.map(t -> new ThemeInfo(t.themeId(), t.themeName()))
					.sorted().toArray(size -> new ThemeInfo[size]));
			
			Template tpl = fmConfig.getTemplate("portal.ftlh");
			tpl.process(portalModel, out);
		} catch (TemplateException e) {
			throw new IOException(e);
		}
	}

	private void sendPortalResource(GetRequest event, IOSubchannel channel,
			String resource) {
		// Look for content
		InputStream in = this.getClass().getResourceAsStream(resource);
		if (in == null) {
			return;
		}
		
		// Send header
		HttpResponse response = event.httpRequest().response().get();
		prepareResourceResponse(response, event.requestUri());
		channel.respond(new Response(response));
		
		// Send content
		activeEventPipeline().executorService()
			.submit(new InputStreamPipeline(in, channel));
		
		// Done
		event.stop();
	}

	private void sendThemeResource(GetRequest event, IOSubchannel channel,
			String resource) {
		// Get resource
		ThemeProvider themeProvider = event.associated(Session.class).flatMap(
				session -> Optional.ofNullable(session.get("themeProvider")).flatMap(
						themeId -> StreamSupport
						.stream(themeLoader().spliterator(), false)
						.filter(t -> t.themeId().equals(themeId)).findFirst()
				)).orElse(baseTheme);
		InputStream resIn;
		try {
			resIn = themeProvider.getResourceAsStream(resource);
		} catch (ResourceNotFoundException e) {
			try {
				resIn = baseTheme.getResourceAsStream(resource);
			} catch (ResourceNotFoundException e1) {
				resIn = fallbackResourceSupplier.apply(themeProvider, resource);
				if (resIn == null) {
					return;
				}
			}
		}

		// Send header
		HttpResponse response = event.httpRequest().response().get();
		prepareResourceResponse(response, event.requestUri());
		channel.respond(new Response(response));

		// Send content
		activeEventPipeline().executorService()
		        .submit(new InputStreamPipeline(resIn, channel));

		// Done
		event.stop();
	}

	public static void prepareResourceResponse(
			HttpResponse response, URI request) {
		response.setContentType(request);
		// Set max age in cache-control header
		List<Directive> directives = new ArrayList<>();
		directives.add(new Directive("max-age", 600));
		response.setField(HttpField.CACHE_CONTROL, directives);
		response.setField(HttpField.LAST_MODIFIED, Instant.now());

		response.setStatus(HttpStatus.OK);
	}

	private void requestPortletResource(GetRequest event, IOSubchannel channel,
			URI resource) throws InterruptedException {
		String resPath = resource.getPath();
		int sep = resPath.indexOf('/');
		// Send events to portlets on portal's channel
		if (Boolean.TRUE.equals(newEventPipeline().fire(
				new PortletResourceRequest(resPath.substring(0, sep), 
						uriFromPath(resPath.substring(sep + 1)),
						event.httpRequest(), channel), portalChannel(channel))
				.get())) {
			event.stop();
		}
	}
	
	@Handler
	public void onInput(Input<ManagedCharBuffer> event, IOSubchannel channel)
			throws IOException {
		Optional<PortalInfo> optPortalInfo 
			= channel.associated(this, PortalInfo.class);
		if (!optPortalInfo.isPresent()) {
			return;
		}
		optPortalInfo.get().toEvent(portalChannel(channel),
				event.buffer().backingBuffer(), event.isEndOfRecord());
	}
	
	/**
	 * Forward the {@link Closed} event to the portal channel.
	 * 
	 * @param event the event
	 * @param channel the channel
	 */
	@Handler
	public void onClosed(Closed event, IOSubchannel channel) {
		fire(new Closed(), portalChannel(channel));
	}
	
	@Handler(dynamic=true)
	public void onPortalReady(PortalReady event, IOSubchannel channel) {
		KeyValueStoreQuery query = new KeyValueStoreQuery(
				"/themeProvider", true);
		channel.setAssociated(this, new CompletionLock(event, 3000));
		fire(query, channel);
	}

	@Handler(dynamic=true)
	public void onKeyValueStoreData(
			KeyValueStoreData event, IOSubchannel channel) 
					throws JsonDecodeException {
		if (!event.event().query().equals("/themeProvider")) {
			return;
		}
		channel.associated(this, CompletionLock.class)
			.ifPresent(lock -> lock.remove());
		if (!event.data().values().iterator().hasNext()) {
			return;
		}
		String requestedThemeId = event.data().values().iterator().next();
		ThemeProvider themeProvider = event.associated(Session.class).flatMap(
				session -> Optional.ofNullable(session.get("themeProvider")).flatMap(
						themeId -> StreamSupport
						.stream(themeLoader().spliterator(), false)
						.filter(t -> t.themeId().equals(themeId)).findFirst()
				)).orElse(baseTheme);
		if (!themeProvider.themeId().equals(requestedThemeId)) {
			fire(new SetTheme(requestedThemeId), channel);
		}
	}
	
	@Handler(dynamic=true)
	public void onPortletResourceResponse(PortletResourceResponse event,
	        LinkedIOSubchannel channel) {
		HttpRequest request = event.request().httpRequest();
		// Send header
		HttpResponse response = request.response().get();
		prepareResourceResponse(response, request.requestUri());
		channel.upstreamChannel().respond(new Response(response));
		
		// Send content
		activeEventPipeline().executorService().submit(
				new InputStreamPipeline(
						event.stream(), channel.upstreamChannel()));<commit_msg>Fixed theme handling.<commit_after>/*
 * JGrapes Event Driven Framework
 * Copyright (C) 2017  <NAME>
 * 
 * This program is free software; you can redistribute it and/or modify it 
 * under the terms of the GNU General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or 
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 
 * for more details.
 * 
 * You should have received a copy of the GNU General Public License along 
 * with this program; if not, see <http://www.gnu.org/licenses/>.
 */

package org.jgrapes.portal;

import freemarker.template.Configuration;
import freemarker.template.SimpleScalar;
import freemarker.template.Template;
import freemarker.template.TemplateException;
import freemarker.template.TemplateExceptionHandler;
import freemarker.template.TemplateMethodModelEx;
import freemarker.template.TemplateModel;
import freemarker.template.TemplateModelException;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.io.PipedReader;
import java.io.PipedWriter;
import java.io.Reader;
import java.io.UnsupportedEncodingException;
import java.io.Writer;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.CharBuffer;
import java.security.Principal;
import java.text.Collator;
import java.text.ParseException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.MissingResourceException;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.stream.StreamSupport;

import javax.json.Json;
import javax.json.JsonReader;

import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
import org.jdrupes.httpcodec.protocols.http.HttpField;
import org.jdrupes.httpcodec.protocols.http.HttpRequest;
import org.jdrupes.httpcodec.protocols.http.HttpResponse;
import org.jdrupes.httpcodec.types.Converters;
import org.jdrupes.httpcodec.types.Directive;
import org.jdrupes.httpcodec.types.MediaType;
import org.jdrupes.json.JsonDecodeException;
import org.jgrapes.core.Channel;
import org.jgrapes.core.CompletionLock;
import org.jgrapes.core.Component;
import org.jgrapes.core.annotation.Handler;
import org.jgrapes.core.events.Error;
import org.jgrapes.http.LanguageSelector.Selection;
import org.jgrapes.http.Session;
import org.jgrapes.http.annotation.RequestHandler;
import org.jgrapes.http.events.GetRequest;
import org.jgrapes.http.events.Response;
import org.jgrapes.http.events.WebSocketAccepted;
import org.jgrapes.io.IOSubchannel;
import org.jgrapes.io.events.Closed;
import org.jgrapes.io.events.Input;
import org.jgrapes.io.events.Output;
import org.jgrapes.io.util.ByteBufferOutputStream;
import org.jgrapes.io.util.CharBufferWriter;
import org.jgrapes.io.util.InputStreamPipeline;
import org.jgrapes.io.util.LinkedIOSubchannel;
import org.jgrapes.io.util.ManagedCharBuffer;
import org.jgrapes.portal.events.JsonInput;
import org.jgrapes.portal.events.JsonOutput;
import org.jgrapes.portal.events.PortalReady;
import org.jgrapes.portal.events.PortletResourceRequest;
import org.jgrapes.portal.events.PortletResourceResponse;
import org.jgrapes.portal.events.SetLocale;
import org.jgrapes.portal.events.SetTheme;
import org.jgrapes.portal.themes.base.Provider;
import org.jgrapes.util.events.KeyValueStoreData;
import org.jgrapes.util.events.KeyValueStoreQuery;
import org.jgrapes.util.events.KeyValueStoreUpdate;

/**
 * 
 */
public class PortalView extends Component {

	private Portal portal;
	private ServiceLoader<ThemeProvider> themeLoader;
	private static Configuration fmConfig = null;
	
	private Function<Locale,ResourceBundle> resourceBundleSupplier;
	private BiFunction<ThemeProvider,String,InputStream> fallbackResourceSupplier
		= (themeProvider, resource) -> { return null; };
	private Set<Locale> supportedLocales;
	
	private ThemeProvider baseTheme;
	private Map<String,Object> portalBaseModel = new HashMap<>();
	private RenderSupport renderSupport = new RenderSupportImpl();

	/**
	 * @param componentChannel
	 */
	public PortalView(Portal portal, Channel componentChannel) {
		super(componentChannel);
		this.portal = portal;
		if (fmConfig == null) {
			fmConfig = new Configuration(Configuration.VERSION_2_3_26);
			fmConfig.setClassLoaderForTemplateLoading(
					getClass().getClassLoader(), "org/jgrapes/portal");
			fmConfig.setDefaultEncoding("utf-8");
			fmConfig.setTemplateExceptionHandler(
					TemplateExceptionHandler.RETHROW_HANDLER);
	        fmConfig.setLogTemplateExceptions(false);
		}
		baseTheme = new Provider();
		
		supportedLocales = new HashSet<>();
		for (Locale locale: Locale.getAvailableLocales()) {
			if (locale.getLanguage().equals("")) {
				continue;
			}
			if (resourceBundleSupplier != null) {
				ResourceBundle rb = resourceBundleSupplier.apply(locale);
				if (rb.getLocale().equals(locale)) {
					supportedLocales.add(locale);
				}
			}
			ResourceBundle rb = ResourceBundle.getBundle(getClass()
					.getPackage().getName()	+ ".l10n", locale);
			if (rb.getLocale().equals(locale)) {
				supportedLocales.add(locale);
			}
		}
		
		RequestHandler.Evaluator.add(this, "onGet",	portal.prefix() + "**");
		RequestHandler.Evaluator.add(this, "onGetRedirect",
				portal.prefix().getPath().substring(
						0, portal.prefix().getPath().length() - 1));
		
		// Create portal model
		portalBaseModel.put("resourceUrl", new TemplateMethodModelEx() {
			@Override
			public Object exec(@SuppressWarnings("rawtypes") List arguments)
					throws TemplateModelException {
				@SuppressWarnings("unchecked")
				List<TemplateModel> args = (List<TemplateModel>)arguments;
				if (!(args.get(0) instanceof SimpleScalar)) {
					throw new TemplateModelException("Not a string.");
				}
				return portal.prefix().resolve(
						((SimpleScalar)args.get(0)).getAsString()).getRawPath();
			}
		});
		
		portalBaseModel = Collections.unmodifiableMap(portalBaseModel);

		// Handlers attached to the portal side channel
		Handler.Evaluator.add(this, "onPortalReady", portal.channel());
		Handler.Evaluator.add(this, "onKeyValueStoreData", portal.channel());
		Handler.Evaluator.add(
				this, "onPortletResourceResponse", portal.channel());
		Handler.Evaluator.add(this, "onJsonOutput", portal.channel());
		Handler.Evaluator.add(this, "onSetLocale", portal.channel());
		Handler.Evaluator.add(this, "onSetTheme", portal.channel());
	}

	/**
	 * The service loader must be created lazily, else the OSGi
	 * service mediator doesn't work properly.
	 * 
	 * @return
	 */
	private ServiceLoader<ThemeProvider> themeLoader() {
		if (themeLoader != null) {
			return themeLoader;
		}
		return themeLoader = ServiceLoader.load(ThemeProvider.class);
	}
	
	void setResourceBundleSupplier(
			Function<Locale,ResourceBundle> supplier) {
		this.resourceBundleSupplier = supplier;
	}
	
	void setFallbackResourceSupplier(
			BiFunction<ThemeProvider,String,InputStream> supplier) {
		this.fallbackResourceSupplier = supplier;
	}
	
	RenderSupport renderSupport() {
		return renderSupport;
	}
	
	private LinkedIOSubchannel portalChannel(IOSubchannel channel) {
		@SuppressWarnings("unchecked")
		Optional<LinkedIOSubchannel> portalChannel
			= (Optional<LinkedIOSubchannel>)LinkedIOSubchannel
				.downstreamChannel(portal, channel);
		return portalChannel.orElseGet(
				() -> new LinkedIOSubchannel(portal, channel));
	}

	@RequestHandler(dynamic=true)
	public void onGetRedirect(GetRequest event, IOSubchannel channel) 
			throws InterruptedException, IOException, ParseException {
		HttpResponse response = event.httpRequest().response().get();
		response.setStatus(HttpStatus.MOVED_PERMANENTLY)
			.setContentType("text", "plain", "utf-8")
			.setField(HttpField.LOCATION, portal.prefix());
		fire(new Response(response), channel);
		try {
			fire(Output.wrap(portal.prefix().toString()
					.getBytes("utf-8"), true), channel);
		} catch (UnsupportedEncodingException e) {
			// Supported by definition
		}
		event.stop();
	}
	
	@RequestHandler(dynamic=true)
	public void onGet(GetRequest event, IOSubchannel channel) 
			throws InterruptedException, IOException {
		URI requestUri = event.requestUri();
		// Append trailing slash, if missing
		if ((requestUri.getRawPath() + "/").equals(
				portal.prefix().getRawPath())) {
			requestUri = portal.prefix();
		}
		
		// Request for portal?
		if (!requestUri.getRawPath().startsWith(portal.prefix().getRawPath())) {
			return;
		}
		
		// Normalize and evaluate
		requestUri = portal.prefix().relativize(
				URI.create(requestUri.getRawPath()));
		if (requestUri.getRawPath().isEmpty()) {
			if (event.httpRequest().findField(
					HttpField.UPGRADE, Converters.STRING_LIST)
					.map(f -> f.value().containsIgnoreCase("websocket"))
					.orElse(false)) {
				channel.setAssociated(this, new PortalInfo());
				channel.respond(new WebSocketAccepted(event));
				event.stop();
				return;
			}
			
			renderPortal(event, channel);
			return;
		}
		URI subUri = uriFromPath("portal-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			sendPortalResource(event, channel, subUri.getPath());
			return;
		}
		subUri = uriFromPath("theme-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			sendThemeResource(event, channel, subUri.getPath());
			return;
		}
		subUri = uriFromPath("portlet-resource/").relativize(requestUri);
		if (!subUri.equals(requestUri)) {
			requestPortletResource(event, channel, subUri);
			return;
		}
	}

	private void renderPortal(GetRequest event, IOSubchannel channel)
		throws IOException, InterruptedException {
		event.stop();
		
		// Because language is changed via websocket, locale cookie 
		// may be out-dated
		event.associated(Selection.class)
			.ifPresent(s ->	s.prefer(s.get()[0]));
		
		// Prepare response
		HttpResponse response = event.httpRequest().response().get();
		MediaType mediaType = MediaType.builder().setType("text", "html")
				.setParameter("charset", "utf-8").build();
		response.setField(HttpField.CONTENT_TYPE, mediaType);
		response.setStatus(HttpStatus.OK);
		response.setHasPayload(true);
		channel.respond(new Response(response));
		try (Writer out = new OutputStreamWriter(new ByteBufferOutputStream(
				channel, channel.responsePipeline()), "utf-8")) {
			Map<String,Object> portalModel = new HashMap<>(portalBaseModel);

			// Add locale
			final Locale locale = event.associated(Selection.class).map(
					s -> s.get()[0]).orElse(Locale.getDefault());
			portalModel.put("locale", locale);

			// Add supported locales
			final Collator coll = Collator.getInstance(locale);
			final Comparator<LanguageInfo> comp 
				= new Comparator<PortalView.LanguageInfo>() {
				@Override
				public int compare(LanguageInfo o1,  LanguageInfo o2) {
					return coll.compare(o1.getLabel(), o2.getLabel());
				}
			};
			LanguageInfo[] languages = supportedLocales.stream()
					.map(l -> new LanguageInfo(l))
					.sorted(comp).toArray(size -> new LanguageInfo[size]);
			portalModel.put("supportedLanguages", languages);

			// Add localization
			final ResourceBundle additionalResources = resourceBundleSupplier == null
					? null : resourceBundleSupplier.apply(locale);
			final ResourceBundle baseResources = ResourceBundle.getBundle(
					getClass().getPackage().getName() + ".l10n", locale,
					ResourceBundle.Control.getNoFallbackControl(
							ResourceBundle.Control.FORMAT_DEFAULT));
			portalModel.put("_", new TemplateMethodModelEx() {
				@Override
				public Object exec(@SuppressWarnings("rawtypes") List arguments)
						throws TemplateModelException {
					@SuppressWarnings("unchecked")
					List<TemplateModel> args = (List<TemplateModel>)arguments;
					if (!(args.get(0) instanceof SimpleScalar)) {
						throw new TemplateModelException("Not a string.");
					}
					String key = ((SimpleScalar)args.get(0)).getAsString();
					try {
						return additionalResources.getString(key);
					} catch (MissingResourceException e) {
						// try base resources
					}
					try {
						return baseResources.getString(key);
					} catch (MissingResourceException e) {
						// no luck
					}
					return key;
				}
			});

			// Add themes. Doing this on every reload allows themes
			// to be added dynamically. Note that we must load again
			// (not reload) in order for this to work in an OSGi environment.
			themeLoader = ServiceLoader.load(ThemeProvider.class);
			portalModel.put("themeInfos", 
					StreamSupport.stream(themeLoader().spliterator(), false)
					.map(t -> new ThemeInfo(t.themeId(), t.themeName()))
					.sorted().toArray(size -> new ThemeInfo[size]));
			
			Template tpl = fmConfig.getTemplate("portal.ftlh");
			tpl.process(portalModel, out);
		} catch (TemplateException e) {
			throw new IOException(e);
		}
	}

	private void sendPortalResource(GetRequest event, IOSubchannel channel,
			String resource) {
		// Look for content
		InputStream in = this.getClass().getResourceAsStream(resource);
		if (in == null) {
			return;
		}
		
		// Send header
		HttpResponse response = event.httpRequest().response().get();
		prepareResourceResponse(response, event.requestUri());
		channel.respond(new Response(response));
		
		// Send content
		activeEventPipeline().executorService()
			.submit(new InputStreamPipeline(in, channel));
		
		// Done
		event.stop();
	}

	private void sendThemeResource(GetRequest event, IOSubchannel channel,
			String resource) {
		// Get resource
		ThemeProvider themeProvider = event.associated(Session.class).flatMap(
				session -> Optional.ofNullable(session.get("themeProvider")).flatMap(
						themeId -> StreamSupport
						.stream(themeLoader().spliterator(), false)
						.filter(t -> t.themeId().equals(themeId)).findFirst()
				)).orElse(baseTheme);
		InputStream resIn;
		try {
			resIn = themeProvider.getResourceAsStream(resource);
		} catch (ResourceNotFoundException e) {
			try {
				resIn = baseTheme.getResourceAsStream(resource);
			} catch (ResourceNotFoundException e1) {
				resIn = fallbackResourceSupplier.apply(themeProvider, resource);
				if (resIn == null) {
					return;
				}
			}
		}

		// Send header
		HttpResponse response = event.httpRequest().response().get();
		prepareResourceResponse(response, event.requestUri());
		channel.respond(new Response(response));

		// Send content
		activeEventPipeline().executorService()
		        .submit(new InputStreamPipeline(resIn, channel));

		// Done
		event.stop();
	}

	public static void prepareResourceResponse(
			HttpResponse response, URI request) {
		response.setContentType(request);
		// Set max age in cache-control header
		List<Directive> directives = new ArrayList<>();
		directives.add(new Directive("max-age", 600));
		response.setField(HttpField.CACHE_CONTROL, directives);
		response.setField(HttpField.LAST_MODIFIED, Instant.now());

		response.setStatus(HttpStatus.OK);
	}

	private void requestPortletResource(GetRequest event, IOSubchannel channel,
			URI resource) throws InterruptedException {
		String resPath = resource.getPath();
		int sep = resPath.indexOf('/');
		// Send events to portlets on portal's channel
		if (Boolean.TRUE.equals(newEventPipeline().fire(
				new PortletResourceRequest(resPath.substring(0, sep), 
						uriFromPath(resPath.substring(sep + 1)),
						event.httpRequest(), channel), portalChannel(channel))
				.get())) {
			event.stop();
		}
	}
	
	@Handler
	public void onInput(Input<ManagedCharBuffer> event, IOSubchannel channel)
			throws IOException {
		Optional<PortalInfo> optPortalInfo 
			= channel.associated(this, PortalInfo.class);
		if (!optPortalInfo.isPresent()) {
			return;
		}
		optPortalInfo.get().toEvent(portalChannel(channel),
				event.buffer().backingBuffer(), event.isEndOfRecord());
	}
	
	/**
	 * Forward the {@link Closed} event to the portal channel.
	 * 
	 * @param event the event
	 * @param channel the channel
	 */
	@Handler
	public void onClosed(Closed event, IOSubchannel channel) {
		fire(new Closed(), portalChannel(channel));
	}
	
	@Handler(dynamic=true)
	public void onPortalReady(PortalReady event, IOSubchannel channel) {
		String principal = channel.associated(Session.class).map(session ->
				session.getOrDefault(Principal.class, "").toString())
				.orElse("");
		KeyValueStoreQuery query = new KeyValueStoreQuery(
				"/" + principal + "/themeProvider", true);
		channel.setAssociated(this, new CompletionLock(event, 3000));
		fire(query, channel);
	}

	@Handler(dynamic=true)
	public void onKeyValueStoreData(
			KeyValueStoreData event, IOSubchannel channel) 
					throws JsonDecodeException {
		Optional<Session> optSession = channel.associated(Session.class);
		if (!optSession.isPresent()) {
			return;
		}
		Session session = optSession.get();
		String principal = session.getOrDefault(Principal.class, "").toString();
		if (!event.event().query().equals("/" + principal + "/themeProvider")) {
			return;
		}
		channel.associated(this, CompletionLock.class)
			.ifPresent(lock -> lock.remove());
		if (!event.data().values().iterator().hasNext()) {
			return;
		}
		String requestedThemeId = event.data().values().iterator().next();
		ThemeProvider themeProvider = Optional.ofNullable(
				session.get("themeProvider")).flatMap(
						themeId -> StreamSupport
						.stream(themeLoader().spliterator(), false)
						.filter(t -> t.themeId().equals(themeId)).findFirst()
				).orElse(baseTheme);
		if (!themeProvider.themeId().equals(requestedThemeId)) {
			fire(new SetTheme(requestedThemeId), channel);
		}
	}
	
	@Handler(dynamic=true)
	public void onPortletResourceResponse(PortletResourceResponse event,
	        LinkedIOSubchannel channel) {
		HttpRequest request = event.request().httpRequest();
		// Send header
		HttpResponse response = request.response().get();
		prepareResourceResponse(response, request.requestUri());
		channel.upstreamChannel().respond(new Response(response));
		
		// Send content
		activeEventPipeline().executorService().submit(
				new InputStreamPipeline(
						event.stream(), channel.upstreamChannel()));
{'fraction_non_alphanumeric': 0.12229076813888737, 'fraction_numerical': 0.002183763716765846, 'mean_word_length': 4.6344201784066446, 'pattern_counts': {'":': 0, '<': 56, '<?xml version=': 0, '>': 88, 'https://': 0, 'lorem ipsum': 0, 'www.': 2, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2649665', 'n_tokens_mistral': 12194, 'n_tokens_neox': 10775, 'n_words': 2240}