/*******************************************************************************
 * Copyright (c) 2014, 2016 IBH SYSTEMS GmbH.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Public License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/legal/epl-v10.html
 *
 * Contributors:
 *     IBH SYSTEMS GmbH - initial API and implementation
 *******************************************************************************/
package org.eclipse.packagedrone.repo.channel.web.channel;

import static com.google.common.net.UrlEscapers.urlPathSegmentEscaper;
import static java.util.Collections.emptyList;
import static java.util.Comparator.comparing;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static java.util.Optional.ofNullable;
import static javax.servlet.annotation.ServletSecurity.EmptyRoleSemantic.PERMIT;
import static org.eclipse.packagedrone.repo.web.CommonCategories.EDIT;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeMap;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.servlet.ServletException;
import javax.servlet.annotation.HttpConstraint;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
import javax.validation.Valid;
import javax.xml.ws.Holder;

import org.apache.http.client.utils.URIBuilder;
import org.eclipse.packagedrone.repo.ChannelAspectInformation;
import org.eclipse.packagedrone.repo.MetaKey;
import org.eclipse.packagedrone.repo.aspect.ChannelAspectProcessor;
import org.eclipse.packagedrone.repo.aspect.group.GroupInformation;
import org.eclipse.packagedrone.repo.aspect.recipe.RecipeInformation;
import org.eclipse.packagedrone.repo.aspect.recipe.RecipeNotFoundException;
import org.eclipse.packagedrone.repo.channel.ArtifactInformation;
import org.eclipse.packagedrone.repo.channel.AspectableChannel;
import org.eclipse.packagedrone.repo.channel.ChannelArtifactInformation;
import org.eclipse.packagedrone.repo.channel.ChannelDetails;
import org.eclipse.packagedrone.repo.channel.ChannelId;
import org.eclipse.packagedrone.repo.channel.ChannelInformation;
import org.eclipse.packagedrone.repo.channel.ChannelNotFoundException;
import org.eclipse.packagedrone.repo.channel.ChannelService;
import org.eclipse.packagedrone.repo.channel.ChannelService.By;
import org.eclipse.packagedrone.repo.channel.ChannelService.ChannelOperation;
import org.eclipse.packagedrone.repo.channel.DeployKeysChannelAdapter;
import org.eclipse.packagedrone.repo.channel.DescriptorAdapter;
import org.eclipse.packagedrone.repo.channel.ModifiableChannel;
import org.eclipse.packagedrone.repo.channel.ReadableChannel;
import org.eclipse.packagedrone.repo.channel.VetoArtifactException;
import org.eclipse.packagedrone.repo.channel.deploy.DeployAuthService;
import org.eclipse.packagedrone.repo.channel.deploy.DeployGroup;
import org.eclipse.packagedrone.repo.channel.deploy.DeployKey;
import org.eclipse.packagedrone.repo.channel.util.DownloadHelper;
import org.eclipse.packagedrone.repo.channel.web.Tags;
import org.eclipse.packagedrone.repo.channel.web.breadcrumbs.Breadcrumbs;
import org.eclipse.packagedrone.repo.channel.web.breadcrumbs.Breadcrumbs.Entry;
import org.eclipse.packagedrone.repo.channel.web.internal.Activator;
import org.eclipse.packagedrone.repo.generator.GeneratorProcessor;
import org.eclipse.packagedrone.repo.manage.system.SitePrefixService;
import org.eclipse.packagedrone.repo.web.sitemap.ChangeFrequency;
import org.eclipse.packagedrone.repo.web.sitemap.SitemapExtender;
import org.eclipse.packagedrone.repo.web.sitemap.UrlSetContext;
import org.eclipse.packagedrone.repo.web.utils.Channels;
import org.eclipse.packagedrone.sec.web.controller.HttpContraintControllerInterceptor;
import org.eclipse.packagedrone.sec.web.controller.Secured;
import org.eclipse.packagedrone.sec.web.controller.SecuredControllerInterceptor;
import org.eclipse.packagedrone.web.Controller;
import org.eclipse.packagedrone.web.LinkTarget;
import org.eclipse.packagedrone.web.ModelAndView;
import org.eclipse.packagedrone.web.RequestMapping;
import org.eclipse.packagedrone.web.RequestMethod;
import org.eclipse.packagedrone.web.ViewResolver;
import org.eclipse.packagedrone.web.common.CommonController;
import org.eclipse.packagedrone.web.common.InterfaceExtender;
import org.eclipse.packagedrone.web.common.Modifier;
import org.eclipse.packagedrone.web.common.menu.MenuEntry;
import org.eclipse.packagedrone.web.common.page.Pagination;
import org.eclipse.packagedrone.web.controller.ControllerInterceptor;
import org.eclipse.packagedrone.web.controller.ProfilerControllerInterceptor;
import org.eclipse.packagedrone.web.controller.binding.BindingResult;
import org.eclipse.packagedrone.web.controller.binding.PathVariable;
import org.eclipse.packagedrone.web.controller.binding.RequestParameter;
import org.eclipse.packagedrone.web.controller.form.FormData;
import org.eclipse.packagedrone.web.controller.validator.ControllerValidator;
import org.eclipse.packagedrone.web.controller.validator.ValidationContext;
import org.eclipse.scada.utils.ExceptionHelper;
import org.osgi.framework.FrameworkUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.common.io.ByteStreams;
import com.google.common.net.UrlEscapers;
import com.google.gson.GsonBuilder;

@Secured
@Controller
@ViewResolver ( "/WEB-INF/views/%s.jsp" )
@ControllerInterceptor ( SecuredControllerInterceptor.class )
@HttpConstraint ( rolesAllowed = "MANAGER" )
@ControllerInterceptor ( HttpContraintControllerInterceptor.class )
@ControllerInterceptor ( ProfilerControllerInterceptor.class )
public class ChannelController implements InterfaceExtender, SitemapExtender
{
    private static final Comparator<ChannelListEntry> CHANNEL_LIST_ENTRY_COMPARATOR = comparing ( ChannelListEntry::getKey, comparing ( ChannelListEntryKey::getModifier ).thenComparing ( comparing ( ChannelListEntryKey::getId, String.CASE_INSENSITIVE_ORDER ) ) );

    private static final int DEFAULT_MAX_WEB_SIZE = 10_000;

    public static final String DRONE_WEB_MAX_LIST_SIZE = "drone.web.maxListSize";

    private static final String DEFAULT_EXAMPLE_KEY = "xxxxx";

    private final static Logger logger = LoggerFactory.getLogger ( ChannelController.class );

    private final static List<MenuEntry> MENU_ENTRIES = Collections.singletonList ( new MenuEntry ( "Channels", 100, new LinkTarget ( "/channel" ), Modifier.DEFAULT, null ) );

    private static final Comparator<ArtifactInformation> TREE_ARTIFACTS_COMPARATOR = Comparator.comparing ( ArtifactInformation::getName ).thenComparing ( ArtifactInformation::getCreationInstant );

    private DeployAuthService deployAuthService;

    private SitePrefixService sitePrefix;

    private ChannelService channelService;

    private final GeneratorProcessor generators = new GeneratorProcessor ( FrameworkUtil.getBundle ( ChannelController.class ).getBundleContext () );

    public void setChannelService ( final ChannelService channelService )
    {
        this.channelService = channelService;
    }

    public void setDeployAuthService ( final DeployAuthService deployAuthService )
    {
        this.deployAuthService = deployAuthService;
    }

    public void setSitePrefixService ( final SitePrefixService sitePrefix )
    {
        this.sitePrefix = sitePrefix;
    }

    public void start ()
    {
        this.generators.open ();
    }

    public void stop ()
    {
        this.generators.close ();
    }

    @Override
    public List<MenuEntry> getMainMenuEntries ( final HttpServletRequest request )
    {
        return MENU_ENTRIES;
    }

    public static class ChannelListEntryKey
    {
        private final Modifier modifier;

        private final String id;

        private final String by;

        public ChannelListEntryKey ( final Modifier modifier, final String id, final String by )
        {
            this.modifier = modifier;
            this.id = id;
            this.by = by;
        }

        public Modifier getModifier ()
        {
            return this.modifier;
        }

        public String getId ()
        {
            return this.id;
        }

        public String getBy ()
        {
            return this.by;
        }
    }

    public static class ChannelListEntry
    {
        private final ChannelListEntryKey key;

        private final ChannelInformation channel;

        public ChannelListEntry ( final ChannelListEntryKey key, final ChannelInformation channel )
        {
            this.key = key;
            this.channel = channel;
        }

        public ChannelListEntryKey getKey ()
        {
            return this.key;
        }

        public ChannelInformation getChannel ()
        {
            return this.channel;
        }
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView list ( @RequestParameter ( value = "start", required = false ) final Integer startPage )
    {
        final ModelAndView result = new ModelAndView ( "channel/list" );

        final List<ChannelListEntry> channels = this.channelService.list ().stream ().flatMap ( ChannelController::toEntry ).collect ( Collectors.toList () );
        channels.sort ( CHANNEL_LIST_ENTRY_COMPARATOR );

        result.put ( "channels", Pagination.paginate ( startPage, 10, channels ) );

        return result;
    }

    private static Stream<ChannelListEntry> toEntry ( final ChannelInformation info )
    {
        final Stream<ChannelListEntry> idStream = Stream.of ( fromChannel ( info, Modifier.PRIMARY, info.getId (), "id" ) );
        final Stream<ChannelListEntry> nameStream = info.getNames ().stream ().map ( name -> fromChannel ( info, Modifier.DEFAULT, name, "name" ) );
        return Stream.concat ( idStream, nameStream );
    }

    private static ChannelListEntry fromChannel ( final ChannelInformation info, final Modifier mod, final String key, final String by )
    {
        return new ChannelListEntry ( new ChannelListEntryKey ( mod, key, by ), info );
    }

    @RequestMapping ( value = "/channel/create", method = RequestMethod.GET )
    public ModelAndView create ()
    {
        this.channelService.create ( "apm", new ChannelDetails (), Collections.emptyMap () );

        return new ModelAndView ( "redirect:/channel" );
    }

    @RequestMapping ( value = "/channel/createDetailed", method = RequestMethod.GET )
    public ModelAndView createDetailed ()
    {
        final Map<String, Object> model = new HashMap<> ( 1 );
        model.put ( "command", new CreateChannel () );
        return new ModelAndView ( "channel/create", model );
    }

    @RequestMapping ( value = "/channel/createDetailed", method = RequestMethod.POST )
    public ModelAndView createDetailedPost ( @Valid @FormData ( "command" ) final CreateChannel data, final BindingResult result )
    {
        if ( !result.hasErrors () )
        {
            final ChannelDetails desc = new ChannelDetails ();
            desc.setDescription ( data.getDescription () );

            final ChannelId channel = this.channelService.create ( "apm", desc, Collections.emptyMap () );
            setChannelNames ( channel, splitChannelNames ( data.getNames () ) );

            return new ModelAndView ( String.format ( "redirect:/channel/%s/view", urlPathSegmentEscaper ().escape ( channel.getId () ) ) );
        }

        return new ModelAndView ( "channel/create" );
    }

    @RequestMapping ( value = "/channel/createWithRecipe", method = RequestMethod.GET )
    public ModelAndView createWithRecipe ()
    {
        final Map<String, Object> model = new HashMap<> ( 2 );

        model.put ( "command", new CreateChannel () );
        model.put ( "recipes", Activator.getRecipes ().getSortedRecipes ( RecipeInformation::getLabel ) );

        return new ModelAndView ( "channel/createWithRecipe", model );
    }

    private static Set<String> splitChannelNames ( final String names )
    {
        final Set<String> result = new HashSet<> ();
        for ( String name : names.split ( "[\\n\\r]+" ) )
        {
            name = name.trim ();
            if ( !name.isEmpty () )
            {
                result.add ( name );
            }
        }
        return result;
    }

    private static String joinChannelNames ( final Collection<String> names )
    {
        return names.stream ().collect ( Collectors.joining ( "\n" ) );
    }

    @RequestMapping ( value = "/channel/createWithRecipe", method = RequestMethod.POST )
    public ModelAndView createWithRecipePost ( @Valid @FormData ( "command" ) final CreateChannel data, @RequestParameter (
            required = false,
            value = "recipe" ) final String recipeId, final BindingResult result ) throws UnsupportedEncodingException, RecipeNotFoundException
    {
        if ( !result.hasErrors () )
        {

            final Holder<ChannelId> holder = new Holder<> ();
            final Holder<String> targetHolder = new Holder<> ();

            if ( recipeId == null || recipeId.isEmpty () )
            {
                // without recipe
                final ChannelDetails desc = new ChannelDetails ();
                desc.setDescription ( data.getDescription () );

                holder.value = this.channelService.create ( "apm", desc, Collections.emptyMap () );
                setChannelNames ( holder.value, splitChannelNames ( data.getNames () ) );
            }
            else
            {
                // with recipe
                Activator.getRecipes ().process ( recipeId, recipe -> {
                    final ChannelDetails desc = new ChannelDetails ();
                    desc.setDescription ( data.getDescription () );

                    final ChannelId channel = this.channelService.create ( "apm", desc, Collections.emptyMap () );

                    setChannelNames ( channel, splitChannelNames ( data.getNames () ) );

                    this.channelService.accessRun ( By.id ( channel.getId () ), AspectableChannel.class, aspChannel -> {

                        final LinkTarget target = recipe.setup ( channel.getId (), aspChannel );

                        if ( target != null )
                        {
                            final Map<String, String> model = new HashMap<> ( 1 );
                            model.put ( "channelId", channel.getId () );
                            targetHolder.value = target.expand ( model ).getUrl ();
                        }
                    } );

                    holder.value = channel;
                } );

                if ( targetHolder.value != null )
                {
                    return new ModelAndView ( "redirect:" + targetHolder.value );
                }
            }

            return new ModelAndView ( String.format ( "redirect:/channel/%s/view", URLEncoder.encode ( holder.value.getId (), "UTF-8" ) ) );
        }

        final Map<String, Object> model = new HashMap<> ( 1 );
        model.put ( "recipes", Activator.getRecipes ().getSortedRecipes ( RecipeInformation::getLabel ) );

        return new ModelAndView ( "channel/createWithRecipe", model );
    }

    protected void setChannelNames ( final ChannelId id, final Collection<String> name )
    {
        this.channelService.accessRun ( By.id ( id.getId () ), DescriptorAdapter.class, channel -> {
            channel.setNames ( name );
        } );
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/view", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView view ( @PathVariable ( "channelId" ) final String channelId, final HttpServletRequest request, final HttpServletResponse response ) throws ServletException, IOException
    {
        final Optional<ChannelInformation> channel = this.channelService.getState ( By.name ( channelId ) );
        if ( channel.isPresent () )
        {
            return new ModelAndView ( String.format ( "redirect:/channel/%s/view", channel.get ().getId () ) );
        }
        else
        {
            request.getRequestDispatcher ( "tree" ).forward ( request, response );
            return null;
        }
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/viewPlain", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView viewPlain ( @PathVariable ( "channelId" ) final String channelId )
    {
        try
        {
            return this.channelService.accessCall ( By.id ( channelId ), ReadableChannel.class, ( channel ) -> {

                final Map<String, Object> model = new HashMap<> ();

                model.put ( "channel", channel.getInformation () );

                final Collection<ArtifactInformation> artifacts = channel.getContext ().getArtifacts ().values ();

                if ( artifacts.size () > maxWebListSize () )
                {
                    return viewTooMany ( channel );
                }

                // sort artifacts

                final List<ArtifactInformation> sortedArtifacts = new ArrayList<> ( artifacts );
                sortedArtifacts.sort ( Comparator.comparing ( ArtifactInformation::getName ) );
                model.put ( "sortedArtifacts", sortedArtifacts );

                return new ModelAndView ( "channel/view", model );
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }
    }

    private ModelAndView viewTooMany ( final ReadableChannel channel )
    {
        final Map<String, Object> model = new HashMap<> ();
        model.put ( "channel", channel.getInformation () );
        model.put ( "numberOfArtifacts", channel.getArtifacts ().size () );
        model.put ( "maxNumberOfArtifacts", maxWebListSize () );
        model.put ( "propertyName", DRONE_WEB_MAX_LIST_SIZE );
        return new ModelAndView ( "channel/viewTooMany", model );
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/tree", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView tree ( @PathVariable ( "channelId" ) final String channelId )
    {
        try
        {
            return this.channelService.accessCall ( By.id ( channelId ), ReadableChannel.class, ( channel ) -> {

                if ( channel.getContext ().getArtifacts ().size () > maxWebListSize () )
                {
                    return viewTooMany ( channel );
                }

                final ModelAndView result = new ModelAndView ( "channel/tree" );

                // build artifacts tree

                final Map<String, List<ArtifactInformation>> tree = new HashMap<> ();

                for ( final ArtifactInformation entry : channel.getContext ().getArtifacts ().values () )
                {
                    final String parentId = entry.getParentId ();

                    List<ArtifactInformation> list = tree.get ( parentId );
                    if ( list == null )
                    {
                        list = new ArrayList<> ( parentId == null ? 1000 : 10 );
                        tree.put ( parentId, list );
                    }
                    list.add ( entry );
                }

                // now sort them

                for ( final List<ArtifactInformation> list : tree.values () )
                {
                    Collections.sort ( list, TREE_ARTIFACTS_COMPARATOR );
                }

                // set result

                result.put ( "channel", channel.getInformation () );
                result.put ( "treeArtifacts", tree );
                result.put ( "treeSeverityTester", new TreeTesterImpl ( tree ) );

                return result;
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }
    }

    private Integer maxWebListSize ()
    {
        return Integer.getInteger ( DRONE_WEB_MAX_LIST_SIZE, DEFAULT_MAX_WEB_SIZE );
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/validation", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView viewValidation ( @PathVariable ( "channelId" ) final String channelId )
    {
        try
        {
            return this.channelService.accessCall ( By.id ( channelId ), ReadableChannel.class, channel -> {
                final ModelAndView result = new ModelAndView ( "channel/validation" );

                result.put ( "channel", channel.getInformation () );
                result.put ( "messages", channel.getInformation ().getState ().getValidationMessages () );
                result.put ( "aspects", Activator.getAspects ().getAspectInformations () );

                return result;
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/details", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView details ( @PathVariable ( "channelId" ) final String channelId )
    {
        final ModelAndView result = new ModelAndView ( "channel/details" );

        try
        {
            this.channelService.accessRun ( By.id ( channelId ), ReadableChannel.class, ( channel ) -> {
                result.put ( "channel", channel.getInformation () );
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }

        return result;
    }

    @RequestMapping ( value = "/channel/{channelId}/delete", method = RequestMethod.GET )
    public ModelAndView delete ( @PathVariable ( "channelId" ) final String channelId )
    {
        final ModelAndView result = new ModelAndView ( "redirect:/channel" );

        if ( this.channelService.delete ( By.id ( channelId ) ) )
        {
            result.put ( "success", String.format ( "Deleted channel %s", channelId ) );
        }
        else
        {
            result.put ( "warning", String.format ( "Unable to delete channel %s. Was not found.", channelId ) );
        }

        return result;
    }

    @RequestMapping ( value = "/channel/{channelId}/artifacts/{artifactId}/delete", method = RequestMethod.GET )
    public ModelAndView deleteArtifact ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId )
    {
        return withChannel ( channelId, ModifiableChannel.class, channel -> {
            channel.getContext ().deleteArtifact ( artifactId );
            return redirectDefaultView ( channelId, true );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/artifacts/{artifactId}/get", method = RequestMethod.GET )
    public void getArtifact ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId, final HttpServletResponse response ) throws IOException
    {
        DownloadHelper.streamArtifact ( response, this.channelService, channelId, artifactId, null, true );
    }

    @RequestMapping ( value = "/channel/{channelId}/artifacts/{artifactId}/dump", method = RequestMethod.GET )
    public void dumpArtifact ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId, final HttpServletResponse response ) throws IOException
    {
        DownloadHelper.streamArtifact ( response, this.channelService, channelId, artifactId, null, false );
    }

    @RequestMapping ( value = "/channel/{channelId}/artifacts/{artifactId}/view", method = RequestMethod.GET )
    public ModelAndView viewArtifact ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {

            final Optional<ChannelArtifactInformation> artifact = channel.getArtifact ( artifactId );
            if ( !artifact.isPresent () )
            {
                return CommonController.createNotFound ( "artifact", artifactId );
            }

            final Map<String, Object> model = new HashMap<String, Object> ( 1 );
            model.put ( "artifact", artifact.get () );
            model.put ( "sortedMetaData", new TreeMap<> ( artifact.get ().getMetaData () ) );

            return new ModelAndView ( "artifact/view", model );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/add", method = RequestMethod.GET )
    public ModelAndView add ( @PathVariable ( "channelId" ) final String channelId )
    {
        final ModelAndView mav = new ModelAndView ( "/channel/add" );

        mav.put ( "generators", this.generators.getInformations ().values () );
        mav.put ( "channelId", channelId );

        return mav;
    }

    @RequestMapping ( value = "/channel/{channelId}/add", method = RequestMethod.POST )
    public ModelAndView addPost ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter (
            required = false, value = "name" ) String name, final @RequestParameter ( "file" ) Part file )
    {
        try
        {
            if ( name == null || name.isEmpty () )
            {
                name = file.getSubmittedFileName ();
            }

            final String finalName = name;

            this.channelService.accessRun ( By.id ( channelId ), ModifiableChannel.class, channel -> {
                channel.getContext ().createArtifact ( file.getInputStream (), finalName, null );
            } );

            return redirectDefaultView ( channelId, true );
        }
        catch ( final Exception e )
        {
            return CommonController.createError ( "Upload", "Upload failed", e );
        }
    }

    @RequestMapping ( value = "/channel/{channelId}/drop", method = RequestMethod.POST )
    public void drop ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( required = false,
            value = "name" ) String name, final @RequestParameter ( "file" ) Part file, final HttpServletResponse response ) throws IOException
    {
        response.setContentType ( "text/plain" );

        try
        {
            if ( name == null || name.isEmpty () )
            {
                name = file.getSubmittedFileName ();
            }

            final String finalName = name;

            this.channelService.accessRun ( By.id ( channelId ), ModifiableChannel.class, channel -> {
                channel.getContext ().createArtifact ( file.getInputStream (), finalName, null );
            } );
        }
        catch ( final Throwable e )
        {
            logger.warn ( "Failed to drop file", e );

            final Throwable cause = ExceptionHelper.getRootCause ( e );

            if ( cause instanceof VetoArtifactException )
            {
                response.setStatus ( HttpServletResponse.SC_CONFLICT );
                response.getWriter ().format ( "Artifact rejected! %s", ( (VetoArtifactException)cause ).getVetoMessage ().orElse ( "No details given" ) );
            }
            else
            {
                response.setStatus ( HttpServletResponse.SC_INTERNAL_SERVER_ERROR );
                response.getWriter ().format ( "Internal error! %s", ExceptionHelper.getMessage ( cause ) );
            }
            return;
        }

        response.setStatus ( HttpServletResponse.SC_OK );
        response.getWriter ().write ( "OK" );
    }

    @RequestMapping ( value = "/channel/{channelId}/clear", method = RequestMethod.GET )
    public ModelAndView clear ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, ModifiableChannel.class, channel -> {
            channel.getContext ().clear ();
            return redirectDefaultView ( channelId, true );
        } );
    }

    protected ModelAndView redirectDefaultView ( final String channelId, final boolean force )
    {
        return new ModelAndView ( ( force ? "redirect" : "referer" ) + ":/channel/" + channelId + "/view" );
    }

    @RequestMapping ( value = "/channel/{channelId}/deployKeys" )
    public ModelAndView deployKeys ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, DeployKeysChannelAdapter.class, deployChannel -> {
            return withChannel ( channelId, ReadableChannel.class, channel -> {
                final Map<String, Object> model = new HashMap<> ();

                final List<DeployGroup> channelDeployGroups = new ArrayList<> ( deployChannel.getDeployGroups () );
                Collections.sort ( channelDeployGroups, DeployGroup.NAME_COMPARATOR );

                model.put ( "channel", channel.getInformation () );
                model.put ( "channelDeployGroups", channelDeployGroups );
                model.put ( "deployGroups", getGroupsForChannel ( channelDeployGroups ) );

                model.put ( "sitePrefix", this.sitePrefix.getSitePrefix () );

                return new ModelAndView ( "channel/deployKeys", model );
            } );
        } );
    }

    protected List<DeployGroup> getGroupsForChannel ( final Collection<DeployGroup> channelDeployGroups )
    {
        final List<DeployGroup> groups = new ArrayList<> ( this.deployAuthService.listGroups ( 0, -1 ) );
        groups.removeAll ( channelDeployGroups );
        Collections.sort ( groups, DeployGroup.NAME_COMPARATOR );
        return groups;
    }

    protected <T> ModelAndView withChannel ( final String channelId, final Class<T> clazz, final ChannelOperation<ModelAndView, T> operation )
    {
        return Channels.withChannel ( this.channelService, channelId, clazz, operation );
    }

    @RequestMapping ( "/channel/{channelId}/help/p2" )
    @Secured ( false )
    @HttpConstraint ( PERMIT )
    public ModelAndView helpP2 ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {
            final Map<String, Object> model = new HashMap<> ();

            model.put ( "channel", channel.getInformation () );
            model.put ( "sitePrefix", this.sitePrefix.getSitePrefix () );

            model.put ( "p2Active", channel.hasAspect ( "p2.repo" ) );

            return new ModelAndView ( "channel/help/p2", model );
        } );
    }

    @RequestMapping ( "/channel/{channelId}/help/api" )
    @Secured ( false )
    @HttpConstraint ( PERMIT )
    public ModelAndView helpApi ( @PathVariable ( "channelId" ) final String channelId, final HttpServletRequest request )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {
            final Map<String, Object> model = new HashMap<> ();

            model.put ( "channel", channel.getInformation () );
            model.put ( "sitePrefix", this.sitePrefix.getSitePrefix () );

            final String exampleKey;
            if ( request.isUserInRole ( "MANAGER" ) )
            {
                final Collection<DeployKey> keys = this.channelService.getChannelDeployKeys ( By.id ( channel.getId ().getId () ) ).orElse ( emptyList () );
                exampleKey = keys.stream ().map ( DeployKey::getKey ).findFirst ().orElse ( DEFAULT_EXAMPLE_KEY );
            }
            else
            {
                exampleKey = DEFAULT_EXAMPLE_KEY;
            }

            model.put ( "hasExampleKey", !DEFAULT_EXAMPLE_KEY.equals ( exampleKey ) );

            model.put ( "exampleKey", exampleKey );
            model.put ( "exampleSitePrefix", makeCredentialsPrefix ( this.sitePrefix.getSitePrefix (), "deploy", exampleKey ) );

            return new ModelAndView ( "channel/help/api", model );
        } );
    }

    private String makeCredentialsPrefix ( final String sitePrefix, final String name, final String password )
    {
        try
        {
            final URIBuilder builder = new URIBuilder ( sitePrefix );

            builder.setUserInfo ( name, password );

            return builder.build ().toString ();
        }
        catch ( final URISyntaxException e )
        {
            return sitePrefix;
        }
    }

    @RequestMapping ( value = "/channel/{channelId}/addDeployGroup", method = RequestMethod.POST )
    public ModelAndView addDeployGroup ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "groupId" ) final String groupId )
    {
        return modifyDeployGroup ( channelId, groupId, DeployKeysChannelAdapter::assignDeployGroup );
    }

    @RequestMapping ( value = "/channel/{channelId}/removeDeployGroup", method = RequestMethod.POST )
    public ModelAndView removeDeployGroup ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "groupId" ) final String groupId )
    {
        return modifyDeployGroup ( channelId, groupId, DeployKeysChannelAdapter::unassignDeployGroup );
    }

    protected ModelAndView modifyDeployGroup ( final String channelId, final String groupId, final BiConsumer<DeployKeysChannelAdapter, String> cons )
    {
        return withChannel ( channelId, DeployKeysChannelAdapter.class, channel -> {
            cons.accept ( channel, groupId );

            return new ModelAndView ( "redirect:/channel/" + channelId + "/deployKeys" );
        } );
    }

    @Secured ( false )
    @RequestMapping ( value = "/channel/{channelId}/aspects", method = RequestMethod.GET )
    @HttpConstraint ( PERMIT )
    public ModelAndView aspects ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {

            final ModelAndView model = new ModelAndView ( "channel/aspects" );

            final ChannelAspectProcessor aspects = Activator.getAspects ();
            final Collection<GroupInformation> groups = aspects.getGroups ();

            model.put ( "channel", channel.getInformation () );

            final Set<String> assigned = channel.getInformation ().getAspectStates ().keySet ();

            final List<AspectInformation> allAspects = AspectInformation.resolve ( groups, aspects.getAspectInformations ().values () );

            final List<AspectInformation> assignedAspects = AspectInformation.filterIds ( allAspects, ( id ) -> assigned.contains ( id ) );
            model.put ( "assignedAspects", assignedAspects );

            model.put ( "groupedAssignedAspects", AspectInformation.group ( assignedAspects ) );

            model.put ( "addAspects", AspectInformation.group ( AspectInformation.filterIds ( allAspects, ( id ) -> !assigned.contains ( id ) ) ) );

            final Map<String, String> nameMap = new HashMap<> ();
            for ( final AspectInformation ai : allAspects )
            {
                nameMap.put ( ai.getFactoryId (), ai.getName () );
            }

            model.put ( "nameMapJson", new GsonBuilder ().create ().toJson ( nameMap ) );

            model.put ( "breadcrumbs", new Breadcrumbs ( new Entry ( "Home", "/" ), Breadcrumbs.create ( "Channel", ChannelController.class, "view", "channelId", channelId ), new Entry ( "Aspects" ) ) );

            return model;
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/viewAspectVersions", method = RequestMethod.GET )
    public ModelAndView viewAspectVersions ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {
            final Map<String, String> states = channel.getInformation ().getAspectStates ();

            final List<ChannelAspectInformation> aspects = Activator.getAspects ().resolve ( states.keySet () );
            Collections.sort ( aspects, ChannelAspectInformation.NAME_COMPARATOR );

            final Map<String, Object> model = new HashMap<> ( 3 );

            model.put ( "channel", channel.getInformation () );
            model.put ( "states", states );
            model.put ( "aspects", aspects );

            return new ModelAndView ( "channel/viewAspectVersions", model );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/lock", method = RequestMethod.GET )
    public ModelAndView lock ( @PathVariable ( "channelId" ) final String channelId )
    {
        try
        {
            this.channelService.accessRun ( By.id ( channelId ), ModifiableChannel.class, channel -> {
                channel.lock ();
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }

        return redirectDefaultView ( channelId, false );
    }

    @RequestMapping ( value = "/channel/{channelId}/unlock", method = RequestMethod.GET )
    public ModelAndView unlock ( @PathVariable ( "channelId" ) final String channelId )
    {
        try
        {
            this.channelService.accessRun ( By.id ( channelId ), ModifiableChannel.class, channel -> {
                channel.unlock ();
            } );
        }
        catch ( final ChannelNotFoundException e )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }

        return redirectDefaultView ( channelId, false );
    }

    @RequestMapping ( value = "/channel/{channelId}/addAspect", method = RequestMethod.POST )
    public ModelAndView addAspect ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "aspect" ) final String aspectFactoryId )
    {
        return withChannel ( channelId, AspectableChannel.class, channel -> {
            channel.addAspects ( false, aspectFactoryId );
            return new ModelAndView ( String.format ( "redirect:aspects", channelId ) );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/addAspectWithDependencies", method = RequestMethod.POST )
    public ModelAndView addAspectWithDependencies ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "aspect" ) final String aspectFactoryId )
    {
        return withChannel ( channelId, AspectableChannel.class, channel -> {
            channel.addAspects ( true, aspectFactoryId );
            return new ModelAndView ( String.format ( "redirect:aspects", channelId ) );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/removeAspect", method = RequestMethod.POST )
    public ModelAndView removeAspect ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "aspect" ) final String aspectFactoryId )
    {
        return withChannel ( channelId, AspectableChannel.class, channel -> {
            channel.removeAspects ( aspectFactoryId );
            return new ModelAndView ( String.format ( "redirect:aspects", channelId ) );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/refreshAspect", method = RequestMethod.POST )
    public ModelAndView refreshAspect ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "aspect" ) final String aspectFactoryId )
    {
        return withChannel ( channelId, AspectableChannel.class, channel -> {
            channel.refreshAspects ( aspectFactoryId );
            return new ModelAndView ( String.format ( "redirect:aspects", channelId ) );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/refreshAllAspects", method = RequestMethod.GET )
    public ModelAndView refreshAllAspects ( @PathVariable ( "channelId" ) final String channelId, final HttpServletRequest request )
    {
        return withChannel ( channelId, AspectableChannel.class, channel -> {
            channel.refreshAspects ();
            return redirectDefaultView ( channelId, false );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/edit", method = RequestMethod.GET )
    public ModelAndView edit ( @PathVariable ( "channelId" ) final String channelId )
    {
        final Map<String, Object> model = new HashMap<> ();

        final Optional<ChannelInformation> info = this.channelService.getState ( By.id ( channelId ) );
        if ( !info.isPresent () )
        {
            return CommonController.createNotFound ( "channel", channelId );
        }

        final EditChannel edit = new EditChannel ();

        final ChannelInformation channel = info.get ();

        edit.setId ( channel.getId () );
        edit.setNames ( joinChannelNames ( channel.getNames () ) );
        edit.setDescription ( channel.getDescription () );

        model.put ( "command", edit );
        model.put ( "breadcrumbs", new Breadcrumbs ( new Entry ( "Home", "/" ), Breadcrumbs.create ( "Channel", ChannelController.class, "view", "channelId", channelId ), new Entry ( "Edit" ) ) );

        return new ModelAndView ( "channel/edit", model );

    }

    @RequestMapping ( value = "/channel/{channelId}/edit", method = RequestMethod.POST )
    public ModelAndView editPost ( @PathVariable ( "channelId" ) final String channelId, @Valid @FormData ( "command" ) final EditChannel data, final BindingResult result )
    {
        if ( !result.hasErrors () )
        {
            this.channelService.accessRun ( By.id ( channelId ), DescriptorAdapter.class, channel -> {
                channel.setNames ( splitChannelNames ( data.getNames () ) );
                channel.setDescription ( data.getDescription () );
            } );

            return redirectDefaultView ( channelId, true );
        }
        else
        {
            final Map<String, Object> model = new HashMap<> ();
            model.put ( "command", data );
            model.put ( "breadcrumbs", new Breadcrumbs ( new Entry ( "Home", "/" ), Breadcrumbs.create ( "Channel", ChannelController.class, "view", "channelId", channelId ), new Entry ( "Edit" ) ) );
            return new ModelAndView ( "channel/edit", model );
        }
    }

    @RequestMapping ( value = "/channel/{channelId}/viewCache", method = RequestMethod.GET )
    @HttpConstraint ( rolesAllowed = { "MANAGER", "ADMIN" } )
    public ModelAndView viewCache ( @PathVariable ( "channelId" ) final String channelId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {
            final Map<String, Object> model = new HashMap<> ();

            model.put ( "channel", channel.getInformation () );
            model.put ( "cacheEntries", channel.getCacheEntries ().values () );

            return new ModelAndView ( "channel/viewCache", model );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/viewCacheEntry", method = RequestMethod.GET )
    @HttpConstraint ( rolesAllowed = { "MANAGER", "ADMIN" } )
    public ModelAndView viewCacheEntry ( @PathVariable ( "channelId" ) final String channelId, @RequestParameter ( "namespace" ) final String namespace, @RequestParameter ( "key" ) final String key, final HttpServletResponse response )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {

            if ( !channel.streamCacheEntry ( new MetaKey ( namespace, key ), entry -> {
                logger.trace ( "Length: {}, Mime: {}", entry.getSize (), entry.getMimeType () );

                response.setContentLengthLong ( entry.getSize () );
                response.setContentType ( entry.getMimeType () );
                response.setHeader ( "Content-Disposition", String.format ( "inline; filename=%s", URLEncoder.encode ( entry.getName (), "UTF-8" ) ) );
                // response.setHeader ( "Content-Disposition", String.format ( "attachment; filename=%s", entry.getName () ) );
                ByteStreams.copy ( entry.getStream (), response.getOutputStream () );
            } ) )
            {
                return CommonController.createNotFound ( "channel cache entry", String.format ( "%s:%s", namespace, key ) );
            }

            return null;
        } );
    }

    @Override
    public List<MenuEntry> getActions ( final HttpServletRequest request, final Object object )
    {
        if ( object instanceof ChannelId )
        {
            final ChannelId channel = (ChannelId)object;

            final Map<String, Object> model = new HashMap<> ( 1 );
            model.put ( "channelId", channel.getId () );

            final List<MenuEntry> result = new LinkedList<> ();

            if ( request.isUserInRole ( "MANAGER" ) )
            {
                if ( object instanceof ChannelInformation )
                {
                    final ChannelInformation channelInformation = (ChannelInformation)object;

                    if ( !channelInformation.getState ().isLocked () )
                    {
                        result.add ( new MenuEntry ( "Add Artifact", 100, LinkTarget.createFromController ( ChannelController.class, "add" ).expand ( model ), Modifier.PRIMARY, null ) );
                        result.add ( new MenuEntry ( "Delete Channel", 400, LinkTarget.createFromController ( ChannelController.class, "delete" ).expand ( model ), Modifier.DANGER, "trash" ).makeModalMessage ( "Delete channel", "Are you sure you want to delete the whole channel?" ) );
                        result.add ( new MenuEntry ( "Clear Channel", 500, LinkTarget.createFromController ( ChannelController.class, "clear" ).expand ( model ), Modifier.WARNING, null ).makeModalMessage ( "Clear channel", "Are you sure you want to delete all artifacts from this channel?" ) );

                        result.add ( new MenuEntry ( "Lock Channel", 600, LinkTarget.createFromController ( ChannelController.class, "lock" ).expand ( model ), Modifier.DEFAULT, null ) );
                    }
                    else
                    {
                        result.add ( new MenuEntry ( "Unlock Channel", 600, LinkTarget.createFromController ( ChannelController.class, "unlock" ).expand ( model ), Modifier.DEFAULT, null ) );
                    }
                }

                result.add ( new MenuEntry ( "Edit", EDIT.getPriority (), "Edit Channel", 200, LinkTarget.createFromController ( ChannelController.class, "edit" ).expand ( model ), Modifier.DEFAULT, null ) );
                result.add ( new MenuEntry ( "Maintenance", 160, "Refresh aspects", 100, LinkTarget.createFromController ( ChannelController.class, "refreshAllAspects" ).expand ( model ), Modifier.SUCCESS, "refresh" ) );
            }

            if ( request.getRemoteUser () != null )
            {
                result.add ( new MenuEntry ( "Edit", EDIT.getPriority (), "Configure Aspects", 300, LinkTarget.createFromController ( ChannelController.class, "aspects" ).expand ( model ), Modifier.DEFAULT, null ) );
            }

            return result;
        }
        else if ( Tags.ACTION_TAG_CHANNELS.equals ( object ) )
        {
            final List<MenuEntry> result = new LinkedList<> ();

            if ( request.isUserInRole ( "MANAGER" ) )
            {
                // result.add ( new MenuEntry ( "Create Channel", 100, LinkTarget.createFromController ( ChannelController.class, "createDetailed" ), Modifier.PRIMARY, null ) );
                result.add ( new MenuEntry ( "Create Channel", 120, LinkTarget.createFromController ( ChannelController.class, "createWithRecipe" ), Modifier.PRIMARY, null ) );
            }

            return result;
        }
        else if ( object instanceof org.eclipse.packagedrone.repo.channel.ChannelArtifactInformation )
        {
            final ChannelArtifactInformation ai = (ChannelArtifactInformation)object;

            final List<MenuEntry> result = new LinkedList<> ();

            final Map<String, Object> model = new HashMap<> ( 2 );
            model.put ( "channelId", ai.getChannelId ().getId () );
            model.put ( "artifactId", ai.getId () );

            if ( request.isUserInRole ( "MANAGER" ) )
            {
                if ( ai.is ( "stored" ) )
                {
                    result.add ( new MenuEntry ( "Attach Artifact", 200, LinkTarget.createFromController ( ChannelController.class, "attachArtifact" ).expand ( model ), Modifier.PRIMARY, null ) );
                    result.add ( new MenuEntry ( "Delete", 1000, LinkTarget.createFromController ( ChannelController.class, "deleteArtifact" ).expand ( model ), Modifier.DANGER, "trash" ) );
                }
            }

            return result;
        }
        return null;
    }

    @Override
    public List<MenuEntry> getViews ( final HttpServletRequest request, final Object object )
    {
        if ( object instanceof ChannelInformation )
        {
            final ChannelInformation channel = (ChannelInformation)object;

            final Map<String, Object> model = new HashMap<> ( 1 );
            model.put ( "channelId", channel.getId () );

            final List<MenuEntry> result = new LinkedList<> ();

            result.add ( new MenuEntry ( "Content", 100, LinkTarget.createFromController ( ChannelController.class, "view" ).expand ( model ), Modifier.DEFAULT, null ) );
            result.add ( new MenuEntry ( "List", 120, LinkTarget.createFromController ( ChannelController.class, "viewPlain" ).expand ( model ), Modifier.DEFAULT, null ) );
            result.add ( new MenuEntry ( "Details", 200, LinkTarget.createFromController ( ChannelController.class, "details" ).expand ( model ), Modifier.DEFAULT, null ) );

            result.add ( new MenuEntry ( null, -1, "Validation", 210, LinkTarget.createFromController ( ChannelController.class, "viewValidation" ).expand ( model ), Modifier.DEFAULT, null ).setBadge ( channel.getState ().getValidationErrorCount () ) );

            if ( request.isUserInRole ( "MANAGER" ) )
            {
                result.add ( new MenuEntry ( "Deploy Keys", 1000, LinkTarget.createFromController ( ChannelController.class, "deployKeys" ).expand ( model ), Modifier.DEFAULT, null ) );
            }

            if ( request.isUserInRole ( "MANAGER" ) || request.isUserInRole ( "ADMIN" ) )
            {
                result.add ( new MenuEntry ( "Internal", 400, "View Cache", 100, LinkTarget.createFromController ( ChannelController.class, "viewCache" ).expand ( model ), Modifier.DEFAULT, null ) );
                result.add ( new MenuEntry ( "Internal", 400, "Aspect Versions", 100, LinkTarget.createFromController ( ChannelController.class, "viewAspectVersions" ).expand ( model ), Modifier.DEFAULT, null ) );
            }

            if ( channel.hasAspect ( "p2.repo" ) )
            {
                result.add ( new MenuEntry ( "Help", Integer.MAX_VALUE, "P2 Repository", 2_000, LinkTarget.createFromController ( ChannelController.class, "helpP2" ).expand ( model ), Modifier.DEFAULT, "info-sign" ) );
            }

            result.add ( new MenuEntry ( "Help", Integer.MAX_VALUE, "API Upload", 1_100, LinkTarget.createFromController ( ChannelController.class, "helpApi" ).expand ( model ), Modifier.DEFAULT, "upload" ) );

            return result;
        }
        return null;
    }

    @ControllerValidator ( formDataClass = CreateChannel.class )
    public void validateCreate ( final CreateChannel data, final ValidationContext ctx )
    {
        validateChannelNames ( null, splitChannelNames ( data.getNames () ), ctx );
    }

    @ControllerValidator ( formDataClass = EditChannel.class )
    public void validateEdit ( final EditChannel data, final ValidationContext ctx )
    {
        validateChannelNames ( data.getId (), splitChannelNames ( data.getNames () ), ctx );
    }

    private void validateChannelNames ( final String id, final Iterable<String> names, final ValidationContext ctx )
    {
        for ( final String name : names )
        {
            validateChannelName ( name, ctx );
            validateChannelNameUnique ( id, name, ctx );
        }
    }

    /**
     * Validate the name of the channel
     *
     * @param name
     *            the channel name to test
     * @param ctx
     *            the validation context
     */
    private static void validateChannelName ( final String name, final ValidationContext ctx )
    {
        if ( name == null || name.isEmpty () )
        {
            return;
        }

        final Matcher m = ChannelService.NAME_PATTERN.matcher ( name );
        if ( !m.matches () )
        {
            ctx.error ( "names", String.format ( "The channel name '%s' must match the pattern '%s'", name, ChannelService.NAME_PATTERN.pattern () ) );
        }
    }

    private void validateChannelNameUnique ( final String id, final String name, final ValidationContext ctx )
    {
        if ( name == null || name.isEmpty () )
        {
            return;
        }

        final ChannelInformation other = this.channelService.getState ( By.name ( name ) ).orElse ( null );

        if ( other == null )
        {
            // no one else has this name right now
            return;
        }

        if ( id != null && other.getId ().equals ( id ) )
        {
            // name was found, but it belongs to ourself -> rename
            return;
        }

        ctx.error ( "names", String.format ( "The channel name '%s' is already in use by channel '%s'", name, other.getId () ) );
    }

    @RequestMapping ( value = "/channel/{channelId}/artifact/{artifactId}/attach", method = RequestMethod.GET )
    public ModelAndView attachArtifact ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId )
    {
        return withChannel ( channelId, ReadableChannel.class, channel -> {
            final Optional<ChannelArtifactInformation> artifact = channel.getArtifact ( artifactId );
            if ( !artifact.isPresent () )
            {
                return CommonController.createNotFound ( "artifact", artifactId );
            }

            return new ModelAndView ( "/artifact/attach", "artifact", artifact.get () );
        } );
    }

    @RequestMapping ( value = "/channel/{channelId}/artifact/{artifactId}/attach", method = RequestMethod.POST )
    public ModelAndView attachArtifactPost ( @PathVariable ( "channelId" ) final String channelId, @PathVariable ( "artifactId" ) final String artifactId, @RequestParameter (
            required = false, value = "name" ) final String name, final @RequestParameter ( "file" ) Part file )
    {
        return withChannel ( channelId, ModifiableChannel.class, channel -> {

            String targetName = name;

            final Optional<ChannelArtifactInformation> parentArtifact = channel.getArtifact ( artifactId );
            if ( !parentArtifact.isPresent () )
            {
                return CommonController.createNotFound ( "artifact", artifactId );
            }

            try
            {
                if ( targetName == null || targetName.isEmpty () )
                {
                    targetName = file.getSubmittedFileName ();
                }

                channel.getContext ().createArtifact ( artifactId, file.getInputStream (), targetName, null );
            }
            catch ( final IOException e )
            {
                return new ModelAndView ( "/error/upload" );
            }

            return new ModelAndView ( "redirect:/channel/" + UrlEscapers.urlPathSegmentEscaper ().escape ( channelId ) + "/view" );
        } );
    }

    @Override
    public void extend ( final UrlSetContext context )
    {
        // add location of channels page

        context.addLocation ( "/channel", ofNullable ( calcLastMod () ), of ( ChangeFrequency.DAILY ), empty () );
    }

    private Instant calcLastMod ()
    {
        Instant globalLastMod = null;

        for ( final ChannelInformation ci : this.channelService.list () )
        {
            final Optional<Instant> lastMod = ofNullable ( ci.getState ().getModificationTimestamp () );

            if ( globalLastMod == null || lastMod.get ().isAfter ( globalLastMod ) )
            {
                globalLastMod = lastMod.get ();
            }
        }
        return globalLastMod;
    }

}


/*
 * SeekableInputStream
 *
 * Author: Lasse Collin <lasse.collin@tukaani.org>
 *
 * This file has been put into the public domain.
 * You can do whatever you want with this file.
 */

package org.tukaani.xz;

import java.io.InputStream;
import java.io.IOException;

/**
 * Input stream with random access support.
 */
public abstract class SeekableInputStream extends InputStream {
    /**
     * Seeks n bytes forward in this stream.
     *

     * This will not seek past the end of the file. If the current position
     * is already at or past the end of the file, this doesn't seek at all
     * and returns 0. Otherwise, if skipping n bytes
     * would cause the position to exceed the stream size, this will do
     * equivalent of seek(length()) and the return value will
     * be adjusted accordingly.
     *

     * If n is negative, the position isn't changed and
     * the return value is 0. It doesn't seek backward
     * because it would conflict with the specification of
     * {@link java.io.InputStream#skip(long) InputStream.skip}.
     *
     * @return      0 if n is negative,
     *              less than n if skipping n
     *              bytes would seek past the end of the file,
     *              n otherwise
     *
     * @throws      IOException might be thrown by {@link #seek(long)}
     */
    public long skip(long n) throws IOException {
        if (n <= 0)
            return 0;

        long size = length();
        long pos = position();
        if (pos >= size)
            return 0;

        if (size - pos < n)
            n = size - pos;

        seek(pos + n);
        return n;
    }

    /**
     * Gets the size of the stream.
     */
    public abstract long length() throws IOException;

    /**
     * Gets the current position in the stream.
     */
    public abstract long position() throws IOException;

    /**
     * Seeks to the specified absolute position in the stream.
     *

     * Seeking past the end of the file should be supported by the subclasses
     * unless there is a good reason to do otherwise. If one has seeked
     * past the end of the stream, read will return
     * -1 to indicate end of stream.
     *
     * @param       pos         new read position in the stream
     *
     * @throws      IOException if pos is negative or if
     *                          a stream-specific I/O error occurs
     */
    public abstract void seek(long pos) throws IOException;
}


/*
 * FilterOptions
 *
 * Authors: Lasse Collin <lasse.collin@tukaani.org>
 *          Igor Pavlov <http://7-zip.org/>
 *
 * This file has been put into the public domain.
 * You can do whatever you want with this file.
 */

package org.tukaani.xz;

import java.io.InputStream;
import java.io.IOException;

/**
 * Base class for filter-specific options classes.
 */
public abstract class FilterOptions implements Cloneable {
    /**
     * Gets how much memory the encoder will need with
     * the given filter chain. This function simply calls
     * getEncoderMemoryUsage() for every filter
     * in the array and returns the sum of the returned values.
     */
    public static int getEncoderMemoryUsage(FilterOptions[] options) {
        int m = 0;

        for (int i = 0; i < options.length; ++i)
            m += options[i].getEncoderMemoryUsage();

        return m;
    }

    /**
     * Gets how much memory the decoder will need with
     * the given filter chain. This function simply calls
     * getDecoderMemoryUsage() for every filter
     * in the array and returns the sum of the returned values.
     */
    public static int getDecoderMemoryUsage(FilterOptions[] options) {
        int m = 0;

        for (int i = 0; i < options.length; ++i)
            m += options[i].getDecoderMemoryUsage();

        return m;
    }

    /**
     * Gets how much memory the encoder will need with these options.
     */
    public abstract int getEncoderMemoryUsage();

    /**
     * Gets a raw (no XZ headers) encoder output stream using these options.
     * Raw streams are an advanced feature. In most cases you want to store
     * the compressed data in the .xz container format instead of using
     * a raw stream. To use this filter in a .xz file, pass this object
     * to XZOutputStream.
     *

     * This is uses ArrayCache.getDefaultCache() as the ArrayCache.
     */
    public FinishableOutputStream getOutputStream(FinishableOutputStream out) {
        return getOutputStream(out, ArrayCache.getDefaultCache());
    }

    /**
     * Gets a raw (no XZ headers) encoder output stream using these options
     * and the given ArrayCache.
     * Raw streams are an advanced feature. In most cases you want to store
     * the compressed data in the .xz container format instead of using
     * a raw stream. To use this filter in a .xz file, pass this object
     * to XZOutputStream.
     */
    public abstract FinishableOutputStream getOutputStream(
            FinishableOutputStream out, ArrayCache arrayCache);

    /**
     * Gets how much memory the decoder will need to decompress the data
     * that was encoded with these options.
     */
    public abstract int getDecoderMemoryUsage();

    /**
     * Gets a raw (no XZ headers) decoder input stream using these options.
     *

     * This is uses ArrayCache.getDefaultCache() as the ArrayCache.
     */
    public InputStream getInputStream(InputStream in) throws IOException {
        return getInputStream(in, ArrayCache.getDefaultCache());
    }

    /**
     * Gets a raw (no XZ headers) decoder input stream using these options
     * and the given ArrayCache.
     */
    public abstract InputStream getInputStream(
            InputStream in, ArrayCache arrayCache) throws IOException;

    abstract FilterEncoder getFilterEncoder();

    FilterOptions() {}
}