001/*
002 *  Copyright 2025 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.odf.content;
017
018import java.io.IOException;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Set;
026import java.util.regex.Pattern;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.ProcessingException;
032import org.apache.cocoon.environment.ObjectModelHelper;
033import org.apache.cocoon.environment.Request;
034import org.apache.cocoon.generation.ServiceableGenerator;
035import org.apache.cocoon.xml.AttributesImpl;
036import org.apache.cocoon.xml.XMLUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.xml.sax.SAXException;
039
040import org.ametys.cms.contenttype.ContentTypesHelper;
041import org.ametys.cms.data.ContentValue;
042import org.ametys.cms.data.RichText;
043import org.ametys.cms.data.RichTextHelper;
044import org.ametys.cms.repository.Content;
045import org.ametys.odf.ODFHelper;
046import org.ametys.odf.program.AbstractProgram;
047import org.ametys.odf.program.Container;
048import org.ametys.odf.program.Program;
049import org.ametys.odf.program.ProgramPart;
050import org.ametys.odf.program.SubProgram;
051import org.ametys.odf.program.TraversableProgramPart;
052import org.ametys.plugins.repository.AmetysObjectResolver;
053import org.ametys.runtime.model.View;
054
055/**
056 * Generator for orientation paths in program.
057 */
058public class ProgramOrientationPathsGenerator extends ServiceableGenerator
059{
060    // FIXME Accept all view names starting with "main" to be able to make the ODF and ODF orientation skins coexist (whereas they use different main views)
061    private static final Pattern __ALLOWED_VIEW_NAMES = Pattern.compile("main[a-z\\-]*");
062    // Max number of characters for description per year of duration
063    private static int _MAX_DESC_LENGTH_PER_YEAR = 160;
064
065    private AmetysObjectResolver _resolver;
066    private ContentTypesHelper _cTypesHelper;
067    private ODFHelper _odfHelper;
068    private RichTextHelper _richTextHelper;
069    
070    
071    @Override
072    public void service(ServiceManager smanager) throws ServiceException
073    {
074        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
075        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
076        _odfHelper = (ODFHelper) smanager.lookup(ODFHelper.ROLE);
077        _richTextHelper = (RichTextHelper) smanager.lookup(RichTextHelper.ROLE);
078    }
079    
080    @Override
081    public void generate() throws IOException, SAXException, ProcessingException
082    {
083        Request request = ObjectModelHelper.getRequest(objectModel);
084        Content content = (Content) request.getAttribute(Content.class.getName());
085        
086        if (content == null)
087        {
088            String contentId = request.getParameter("contentId");
089            if (StringUtils.isBlank(contentId))
090            {
091                throw new IllegalArgumentException("Content is missing in request attribute or parameters");
092            }
093            
094            content = _resolver.resolveById(contentId);
095        }
096
097        if (!(content instanceof AbstractProgram abstractProgram))
098        {
099            throw new IllegalArgumentException("Cannot get orientation paths of a non abstract program '" + content.getId() + "'");
100        }
101        
102        String viewName = parameters.getParameter("viewName", StringUtils.EMPTY);
103        String fallbackViewName = parameters.getParameter("fallbackViewName", StringUtils.EMPTY);
104        
105        View view = _cTypesHelper.getViewWithFallback(viewName, fallbackViewName, content);
106        
107        // only allow view starting with "main" to optimize performances when structure is not needed
108        if (view == null || !__ALLOWED_VIEW_NAMES.matcher(view.getName()).matches())
109        {
110            contentHandler.startDocument();
111            XMLUtils.createElement(contentHandler, "orientation-paths");
112            contentHandler.endDocument();
113            return;
114        }
115        
116        // Extract all years first to determine min and max levels
117        List<YearContainer> allYears = _extractAllYears(abstractProgram);
118        
119        // Analyze the program structure to extract orientation paths
120        List<OrientationPath> orientationPaths = _computePaths(allYears);
121        
122        contentHandler.startDocument();
123        
124        int minYear = orientationPaths.stream()
125                                      .mapToInt(OrientationPath::minYear)
126                                      .min()
127                                      .orElse(-1);
128
129        int maxYear = orientationPaths.stream()
130                                      .mapToInt(OrientationPath::maxYear)
131                                      .max()
132                                      .orElse(-1);
133
134        AttributesImpl attr = new AttributesImpl();
135        attr.addCDATAAttribute("minYear", String.valueOf(minYear));
136        attr.addCDATAAttribute("maxYear", String.valueOf(maxYear));
137
138        XMLUtils.startElement(contentHandler, "orientation-paths", attr);
139
140        // Generate SAX events for each orientation path
141        for (OrientationPath path : orientationPaths)
142        {
143            _saxOrientationPath(path);
144        }
145        
146        XMLUtils.endElement(contentHandler, "orientation-paths");
147        
148        contentHandler.endDocument();
149    }
150    
151    private List<YearContainer> _extractAllYears(AbstractProgram abstractProgram)
152    {
153        List<YearContainer> result = new ArrayList<>();
154        
155        Set<Container> years = _odfHelper.getYears(abstractProgram);
156        Set<String> yearIds = years.stream().map(Container::getId).collect(Collectors.toSet());
157
158        for (Container year : years)
159        {
160            // Extract year level from period (annee1 -> 1, annee2 -> 2, etc.)
161            int yearLevel = -1;
162            
163            try
164            {
165                String period = year.getValue("period/code");
166                if (StringUtils.isNotBlank(period) && period.startsWith("annee") && period.length() > 5)
167                {
168                    // Extract number from "anneeX" pattern
169                    String numberStr = period.substring(5); // Skip "annee"
170                    yearLevel = Integer.parseInt(numberStr);
171                }
172            }
173            catch (Exception e)
174            {
175                getLogger().warn("Failed to extract year level from container " + year.getId(), e);
176            }
177
178            if (yearLevel > 0)
179            {
180                // Filter previousYears and nextYears to only include those present in the current program part
181                
182                ContentValue[] previousYears = year.getValue("previousYears");
183                List<ContentValue> currentPreviousYears = previousYears != null ? Arrays.stream(previousYears).filter(y -> yearIds.contains(y.getContentId())).toList() : List.of();
184
185                ContentValue[] nextYears = year.getValue("nextYears");
186                List<ContentValue> currentNextYears = nextYears != null ? Arrays.stream(nextYears).filter(y -> yearIds.contains(y.getContentId())).toList() : List.of();
187
188                result.add(new YearContainer(year, yearLevel, (TraversableProgramPart) _odfHelper.getParentProgramItem(year, abstractProgram), currentPreviousYears, currentNextYears));
189            }
190        }
191        
192        return result;
193    }
194    
195    private List<OrientationPath> _computePaths(List<YearContainer> allYears)
196    {
197        // Build a map for quick lookup by container ID
198        Map<String, YearContainer> yearsMap = allYears.stream()
199                                                      .collect(Collectors.toMap(y -> y.container().getId(), y -> y));
200        
201        // Find starting years: those without previousYears and with a valid period
202        List<YearContainer> startingYears = allYears.stream()
203                                                    .filter(y -> y.previousYears().isEmpty() && y.yearLevel() > 0)
204                                                    .toList();
205        
206        // Build paths starting from each starting year
207        List<OrientationPath> paths = new ArrayList<>();
208        for (YearContainer startYear : startingYears)
209        {
210            OrientationPath path = _buildPath(startYear, yearsMap);
211            
212            if (path != null)
213            {
214                paths.add(path);
215            }
216        }
217        
218        return paths;
219    }
220    
221    private OrientationPath _buildPath(YearContainer fromYear, Map<String, YearContainer> yearsMap)
222    {
223        List<PathPart> parts = _getParts(fromYear, yearsMap, new HashSet<>());
224        
225        if (parts == null || parts.isEmpty())
226        {
227            return null;
228        }
229        
230        int minYear = parts.stream().mapToInt(PathPart::minYear).min().orElse(-1);
231        int maxYear = parts.stream().mapToInt(PathPart::maxYear).max().orElse(-1);
232
233        return new OrientationPath(minYear, maxYear, parts);
234    }
235        
236    private List<PathPart> _getParts(YearContainer fromYear, Map<String, YearContainer> yearsMap, Set<String> visitedYears)
237    {        
238        String currentYearId = fromYear.container().getId();
239        
240        // Check for cycles
241        if (visitedYears.contains(currentYearId))
242        {
243            getLogger().warn("Cycle detected at year " + currentYearId + ". Stopping path construction.");
244            return null;
245        }
246        
247        visitedYears.add(currentYearId);
248        
249        // Group consecutive years under the same parent using nextYears
250        List<YearContainer> groupedYears = new ArrayList<>();
251        groupedYears.add(fromYear);
252
253        YearContainer lastYear = fromYear;
254
255        // Follow nextYears chain as long as they stay in the same parent
256        boolean keepGrouping = true;
257        while (keepGrouping)
258        {
259            YearContainer currentLastYear = lastYear;
260            
261            // Check if there's exactly one next year and in the same parent
262            List<ContentValue> nextYears = currentLastYear.nextYears();
263            if (nextYears.size() == 1)
264            {
265                YearContainer nextYear = yearsMap.get(nextYears.get(0).getContentId());
266                if (nextYear != null && nextYear.parent().getId().equals(currentLastYear.parent().getId()))
267                {
268                    groupedYears.add(nextYear);
269                    visitedYears.add(nextYear.container().getId());
270                    lastYear = nextYear;
271                    continue;
272                }
273            }
274            
275            // No single next year in same parent - stop grouping
276            keepGrouping = false;
277        }
278        
279        // Check if the grouped years represent ALL years in the parent
280        TraversableProgramPart parent = fromYear.parent();
281        boolean groupContainsAllYearsInParent = groupedYears.size() == parent.getProgramPartChildren().size();
282        
283        List<PathPart> parts = new ArrayList<>();
284
285        if (groupContainsAllYearsInParent)
286        {
287            // Create a PathPart for the grouped years
288            parts.add(_createGroupedPathPart(groupedYears, parent));
289        }
290        else
291        {
292            for (YearContainer year : groupedYears)
293            {
294                // Create a PathPart for each year individually
295                parts.add(_createSingleYearPathPart(year));
296            }
297        }
298        
299        // Get all next years from the last year in the group
300        List<String> allNextYearIds = lastYear.nextYears().stream()
301                                              .map(cv -> cv.getContentId())
302                                              .toList();
303        
304        // Continue building path for each next year (handles bifurcations)
305        for (String nextYearId : allNextYearIds)
306        {
307            YearContainer nextYear = yearsMap.get(nextYearId);
308            if (nextYear != null && !visitedYears.contains(nextYearId))
309            {
310                parts.addAll(_getParts(nextYear, yearsMap, visitedYears));
311            }
312        }
313        
314        return parts;
315    }
316    
317    private PathPart _createSingleYearPathPart(YearContainer year)
318    {
319        String id = "part-" + year.container().getId();
320        int yearLevel = year.yearLevel();
321        RichText desc = year.container().getDescription();
322        String description = desc != null ? _richTextHelper.richTextToString(desc, _MAX_DESC_LENGTH_PER_YEAR) : "";
323        
324        List<String> nextParts = year.nextYears().stream().map(y -> "part-" + y.getContentId()).toList();
325
326        return new PathPart(id, year.parent().getId(), _getParentType(year.parent()), year.container().getTitle(), description, yearLevel, yearLevel, List.of(year.container()), nextParts, false);
327    }
328    
329    private PathPart _createGroupedPathPart(List<YearContainer> years, ProgramPart parent)
330    {
331        YearContainer firstYear = years.get(0);
332        YearContainer lastYear = years.get(years.size() - 1);
333        String id = "part-" + firstYear.container().getId();
334        int minYear = firstYear.yearLevel();
335        int maxYear = lastYear.yearLevel();
336        
337        RichText desc;
338        if (parent instanceof AbstractProgram abstractProgram)
339        {
340            desc = abstractProgram.getPresentation();
341        }
342        else
343        {
344            desc = ((Container) parent).getDescription();
345        }
346
347        // Adapt description length based on duration
348        String description = desc != null ? _richTextHelper.richTextToString(desc, (maxYear - minYear + 1) * _MAX_DESC_LENGTH_PER_YEAR) : "";
349        
350        List<Container> containers = years.stream().map(y -> y.container()).toList();
351        List<String> nextParts = lastYear.nextYears().stream().map(y -> "part-" + y.getContentId()).toList();
352
353        return new PathPart(id, parent.getId(), _getParentType(parent), ((Content) parent).getTitle(), description, minYear, maxYear, containers, nextParts, true);
354    }
355    
356    private String _getParentType(ProgramPart parent)
357    {
358        if (parent instanceof SubProgram)
359        {
360            return "subProgram";
361        }
362        else if (parent instanceof Program)
363        {
364            return "program";
365        }
366        else
367        {
368            return "container";
369        }
370    }
371    
372    private void _saxOrientationPath(OrientationPath path) throws SAXException
373    {
374        AttributesImpl pathAttrs = new AttributesImpl();
375        pathAttrs.addCDATAAttribute("minYear", String.valueOf(path.minYear()));
376        pathAttrs.addCDATAAttribute("maxYear", String.valueOf(path.maxYear()));
377        pathAttrs.addCDATAAttribute("duration", String.valueOf(path.maxYear() - path.minYear() + 1));
378        XMLUtils.startElement(contentHandler, "path", pathAttrs);
379        
380        for (PathPart part : path.parts())
381        {
382            _saxPathPart(part);
383        }
384        
385        XMLUtils.endElement(contentHandler, "path");
386    }
387    
388    private void _saxPathPart(PathPart part) throws SAXException
389    {
390        AttributesImpl partAttrs = new AttributesImpl();
391        
392        partAttrs.addCDATAAttribute("id", part.id());
393        partAttrs.addCDATAAttribute("parentId", part.parentId());
394        partAttrs.addCDATAAttribute("parentType", part.parentType());
395        partAttrs.addCDATAAttribute("minYear", String.valueOf(part.minYear()));
396        partAttrs.addCDATAAttribute("maxYear", String.valueOf(part.maxYear()));
397        partAttrs.addCDATAAttribute("duration", String.valueOf(part.maxYear() - part.minYear() + 1));
398        partAttrs.addCDATAAttribute("isGrouped", String.valueOf(part.isGrouped()));
399        
400        XMLUtils.startElement(contentHandler, "part", partAttrs);
401        
402        XMLUtils.createElement(contentHandler, "title", Objects.toString(part.title(), ""));
403        XMLUtils.createElement(contentHandler, "description", Objects.toString(part.description(), ""));
404        
405        // Generate container references
406        XMLUtils.startElement(contentHandler, "containers");
407        
408        for (Container container : part.containers())
409        {
410            AttributesImpl attr = new AttributesImpl();
411            attr.addCDATAAttribute("id", container.getId());
412            XMLUtils.createElement(contentHandler, "container", attr);
413        }
414        
415        XMLUtils.endElement(contentHandler, "containers");
416        
417        // Generate next parts references
418        XMLUtils.startElement(contentHandler, "next-parts");
419        
420        for (String nextId : part.nextPartIds())
421        {
422            AttributesImpl attr = new AttributesImpl();
423            attr.addCDATAAttribute("ref", nextId);
424            XMLUtils.createElement(contentHandler, "part", attr);
425        }
426
427        XMLUtils.endElement(contentHandler, "next-parts");
428        
429        XMLUtils.endElement(contentHandler, "part");
430    }
431    
432    private record YearContainer(Container container, int yearLevel, TraversableProgramPart parent, List<ContentValue> previousYears, List<ContentValue> nextYears) { /* empty */ }
433    
434    /**
435     * Represents a part of a path, which can span multiple consecutive years under the same parent.
436     * Years are grouped together if they share the same parent.
437     * @param id unique identifier for this path part
438     * @param parentId ID of the parent (Program or SubProgram) containing these years
439     * @param parentType the type of the parent
440     * @param title title describing this part of the path
441     * @param description description of this part
442     * @param minYear minimum year level in this path
443     * @param maxYear maximum year level in this path
444     * @param containers list of containers that make up this part
445     * @param nextPartIds list of possible next path part IDs (for bifurcations)
446     * @param isGrouped indicates if this part represents grouped years
447     */
448    public record PathPart(String id, String parentId, String parentType, String title, String description, int minYear, int maxYear, List<Container> containers, List<String> nextPartIds, boolean isGrouped) { /* empty */ }
449    
450    /**
451     * Represents a complete orientation path in the program.
452     * @param minYear minimum year level in this path
453     * @param maxYear maximum year level in this path
454     * @param parts ordered list of path parts that make up this path
455     */
456    public record OrientationPath(int minYear, int maxYear, List<PathPart> parts) { /* empty */ }
457}