001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.widgets;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.time.*;
022import java.time.format.*;
023import java.util.*;
024
025import org.apache.juneau.commons.http.*;
026
027/**
028 * A single calendar event — also the element type of the per-month event GET envelope's {@code events}.
029 *
030 * <p>
031 * Bean-only ({@code juneau-console-widgets}): the emitter and runtime live in {@code juneau-console-views}.
032 * Titles, tooltips, and labels are painted with {@code textContent} by the runtime, never as markup.
033 *
034 * <h5 class='section'>{@code end} is layout-significant, with split inclusivity:</h5>
035 * <ul>
036 *    <li><b>All-day (date-only {@code end}):</b> <b>inclusive</b>.  {@code start=2026-03-02}, {@code end=2026-03-04}
037 *       occupies <b>three</b> day cells.
038 *    <li><b>Timed (date-time {@code end}):</b> <b>exclusive</b>.  {@code start=…T09:00}, {@code end=…T10:00} occupies
039 *       <code>[09:00, 10:00)</code> &mdash; the start day only.  A timed event crossing midnight is a spanning bar
040 *       using the exclusive {@code end}, never intra-day chips on each crossed day.
041 *    <li><b>Omitted {@code end}:</b> start-only, and <b>valid</b>.  An all-day event occupies the start day cell; a
042 *       timed event occupies the start instant as a zero-width chip on the start day (never a spanning bar).
043 *    <li>An all-day {@code end} equal to {@code start} is a single-day event; a <b>timed</b> {@code end} equal to
044 *       {@code start} is zero-duration and is rejected, as is any {@code end} before {@code start}.
045 * </ul>
046 *
047 * <h5 class='section'>All-day agreement and the malformed set:</h5>
048 * <p>
049 * Agreement between {@code allDay} and the shape of {@code end} is decided by {@link #effectiveAllDay()}, never by
050 * the raw {@link #allDay} field, so a <jk>null</jk> {@code allDay} paired with matching {@code start}/{@code end}
051 * shapes stays valid.  An event is <b>malformed</b> for exactly these reasons &mdash; and no others:
052 * <ol>
053 *    <li>a missing/blank {@link #id}, {@link #title}, or {@link #start};
054 *    <li>an unparseable {@code start}/{@code end} civil date, or an {@code end} that is not after (all-day: not on or
055 *       after) {@code start};
056 *    <li>a declared {@code allDay}/{@code end} disagreement &mdash; {@code allDay == } <jk>true</jk> with a date-time
057 *       {@code end}, or {@code allDay == } <jk>false</jk> with a date-only {@code end};
058 *    <li>mixed {@code start}/{@code end} shapes with a <jk>null</jk> {@code allDay}.
059 * </ol>
060 * <p>
061 * A malformed event is <b>dropped</b> by {@link CalendarDef#validate()} and by a per-month GET handler that filters
062 * through {@link #retainWellFormed(Collection)} &mdash; the same rule on both paths.  Neither fails the whole
063 * definition nor the whole month payload.  An omitted {@code end} is <b>not</b> malformed.
064 *
065 * <h5 class='section'>Timezone contract:</h5>
066 * <p>
067 * {@code start}/{@code end} are interpreted as an ISO-8601 local date ({@code yyyy-MM-dd}) or local date-time
068 * ({@code yyyy-MM-ddTHH:mm}).  A trailing offset or {@code Z} is <b>fail-soft ignored, never rejected</b> &mdash; a
069 * seeded {@code 2026-03-02T09:00:00Z} keeps validating and keeps rendering at 09:00 in the server's injected clock
070 * zone.  The same parse applies on the seed path and on the per-month GET path.
071 *
072 * @since 10.0.0
073 */
074@SuppressWarnings({
075   "java:S1845" // Fluent-builder setters intentionally mirror field names (Juneau DSL convention).
076})
077public class CalendarEvent {
078
079   /** Stable id; required, non-blank.  Used for de-duplication and coalesce keys. */
080   public String id;
081
082   /** Required, non-blank display title; painted with {@code textContent}. */
083   public String title;
084
085   /** Required ISO-8601 date ({@code yyyy-MM-dd}) or date-time; the civil date drives chip placement. */
086   public String start;
087
088   /**
089    * Optional ISO-8601 date/date-time bound.  Layout-significant: <b>inclusive</b> for an all-day (date-only)
090    * value, <b>exclusive</b> for a timed (date-time) value.  Omitting it is valid and means start-only.
091    */
092   public String end;
093
094   /** Optional all-day flag; defaults to <jk>true</jk> when {@code start} is a date with no time (see {@link #effectiveAllDay()}). */
095   public Boolean allDay;
096
097   /** Optional category id; must reference a declared {@link EventCategory} for a seed event; unknown wire ids paint {@code NEUTRAL}. */
098   public String categoryId;
099
100   /** Optional same-origin <b>document</b> URL ({@link SafePathTemplate#isSafeDocumentUrl(String)} semantics). */
101   public String href;
102
103   /** Optional tooltip; painted with {@code textContent}. */
104   public String tooltip;
105
106   /**
107    * Creates an empty event.
108    *
109    * @return A new {@link CalendarEvent}.
110    */
111   public static CalendarEvent create() {
112      return new CalendarEvent();
113   }
114
115   /**
116    * Sets the stable id.
117    *
118    * @param value The id.  Must not be <jk>null</jk> or blank.
119    * @return This object.
120    */
121   public CalendarEvent id(String value) {
122      id = value;
123      return this;
124   }
125
126   /**
127    * Sets the display title.
128    *
129    * @param value The title.  Must not be <jk>null</jk> or blank.
130    * @return This object.
131    */
132   public CalendarEvent title(String value) {
133      title = value;
134      return this;
135   }
136
137   /**
138    * Sets the ISO-8601 start.
139    *
140    * @param value A {@code yyyy-MM-dd} date or ISO date-time.  Must not be <jk>null</jk>.
141    * @return This object.
142    */
143   public CalendarEvent start(String value) {
144      start = value;
145      return this;
146   }
147
148   /**
149    * Sets the optional ISO-8601 end.
150    *
151    * @param value A {@code yyyy-MM-dd} date or ISO date-time; must be {@code >= start} when present.
152    * @return This object.
153    */
154   public CalendarEvent end(String value) {
155      end = value;
156      return this;
157   }
158
159   /**
160    * Sets the all-day flag.
161    *
162    * @param value The all-day flag.
163    * @return This object.
164    */
165   public CalendarEvent allDay(boolean value) {
166      allDay = value;
167      return this;
168   }
169
170   /**
171    * Sets the category id.
172    *
173    * @param value The category id.
174    * @return This object.
175    */
176   public CalendarEvent categoryId(String value) {
177      categoryId = value;
178      return this;
179   }
180
181   /**
182    * Sets the same-origin document href.
183    *
184    * @param value The href.
185    * @return This object.
186    */
187   public CalendarEvent href(String value) {
188      href = value;
189      return this;
190   }
191
192   /**
193    * Sets the tooltip.
194    *
195    * @param value The tooltip.
196    * @return This object.
197    */
198   public CalendarEvent tooltip(String value) {
199      tooltip = value;
200      return this;
201   }
202
203   /**
204    * The effective all-day flag: the declared {@link #allDay} when set, otherwise <jk>true</jk> when {@code start}
205    * is a date with no time component.
206    *
207    * @return The effective all-day flag.
208    */
209   public boolean effectiveAllDay() {
210      if (allDay != null)
211         return allDay;
212      return start != null && !start.contains("T");
213   }
214
215   /**
216    * The civil date the chip paints on &mdash; the {@code yyyy-MM-dd} prefix of {@code start} (a date-time
217    * {@code start} is coerced to its leading civil date; a trailing offset is ignored).
218    *
219    * @return The civil start date.
220    * @throws IllegalArgumentException If {@code start} is null or not a parseable ISO date/date-time.
221    */
222   public LocalDate civilStart() {
223      return parseCivil(start, "start");
224   }
225
226   /**
227    * The civil date of {@code end}, or <jk>null</jk> when {@code end} is omitted.
228    *
229    * <p>
230    * This is the raw civil date of the bound, <b>before</b> inclusivity is applied &mdash; use {@link #lastDay()}
231    * for the last day cell the event actually occupies.
232    *
233    * @return The civil end date, or <jk>null</jk> when {@code end} is omitted or blank.
234    * @throws IllegalArgumentException If {@code end} is present but not a parseable ISO date/date-time.
235    */
236   public LocalDate civilEnd() {
237      return hasEnd() ? parseCivil(end, "end") : null;
238   }
239
240   /**
241    * The last day cell this event occupies, applying the split inclusivity rule (see the class javadoc).
242    *
243    * <p>
244    * Equals {@link #civilStart()} when {@code end} is omitted.  For an all-day event the date-only {@code end} is
245    * inclusive; for a timed event the date-time {@code end} is exclusive, so an end of exactly midnight lands on
246    * the previous day.  Never earlier than {@link #civilStart()}.
247    *
248    * @return The inclusive last occupied day.
249    * @throws IllegalArgumentException If {@code start}/{@code end} are not parseable.
250    */
251   public LocalDate lastDay() {
252      var startDate = civilStart();
253      if (!hasEnd())
254         return startDate;
255      var endDate = parseCivil(end, "end");
256      if (effectiveAllDay())
257         return endDate.isBefore(startDate) ? startDate : endDate;
258      // Exclusive bound: the last occupied instant is one nanosecond before end, so a midnight end stays on the
259      // previous day.  An unparseable time degrades to midnight rather than rejecting the event.
260      var last = endDate.atTime(civilTimeOrMidnight(end)).minusNanos(1).toLocalDate();
261      return last.isBefore(startDate) ? startDate : last;
262   }
263
264   /**
265    * Whether this event covers more than one day cell and therefore renders as a spanning bar rather than a chip.
266    *
267    * @return <jk>true</jk> if {@link #lastDay()} is after {@link #civilStart()}.
268    * @throws IllegalArgumentException If {@code start}/{@code end} are not parseable.
269    */
270   public boolean spanning() {
271      return lastDay().isAfter(civilStart());
272   }
273
274   /**
275    * The leading {@code HH:mm} time label a timed chip renders, or <jk>null</jk> for an all-day event or an
276    * unparseable time.
277    *
278    * @return The {@code HH:mm} label, or <jk>null</jk>.
279    */
280   public String startTimeLabel() {
281      if (effectiveAllDay())
282         return null;
283      var t = civilTime(start);
284      return t == null ? null : "%02d:%02d".formatted(t.getHour(), t.getMinute());
285   }
286
287   /**
288    * The reason this event is malformed, or <jk>null</jk> when it is well-formed.
289    *
290    * <p>
291    * This is the single drop predicate shared by {@link CalendarDef#validate()} (the seed path) and by
292    * {@link #retainWellFormed(Collection)} (the per-month GET path); see the class javadoc for the complete,
293    * closed list of malformed reasons.  It is a pure shape check: an unknown {@code categoryId}, an unsafe
294    * {@code href}, and an off-month {@code start} are author errors reported by {@link #validate(Set,Integer,Integer)},
295    * not drop reasons.
296    *
297    * @return A human-readable reason, or <jk>null</jk> when this event is well-formed.
298    */
299   public String malformedReason() {
300      if (ib(id))
301         return "id must not be null or blank";
302      if (ib(title))
303         return "title must not be null or blank";
304      if (civilOrNull(start) == null)
305         return "start is not a valid ISO date or date-time: " + start;
306      if (!hasEnd())
307         return null;  // Omitted end is start-only, and valid.
308      var startDate = civilOrNull(start);
309      var endDate = civilOrNull(end);
310      if (endDate == null)
311         return "end is not a valid ISO date or date-time: " + end;
312      if (effectiveAllDay() != isDateOnly(end))
313         return "end shape disagrees with the effective all-day flag: " + end;
314      if (effectiveAllDay())
315         return endDate.isBefore(startDate) ? "end must be >= start" : null;
316      var startAt = startDate.atTime(civilTimeOrMidnight(start));
317      var endAt = endDate.atTime(civilTimeOrMidnight(end));
318      // Exclusive end: a timed [t, t) interval is empty, so equality is rejected as well as inversion.
319      return endAt.isAfter(startAt) ? null : "timed end must be after start";
320   }
321
322   /**
323    * Returns the well-formed events of the given collection, dropping the malformed ones and continuing.
324    *
325    * <p>
326    * A per-month GET handler filters its payload through this before serialization so the dynamic path drops
327    * exactly what {@link CalendarDef#validate()} drops on the seed path.  A <jk>null</jk> element is dropped too.
328    *
329    * @param values The events to filter.  May be <jk>null</jk>.
330    * @return A new mutable list of the well-formed events, in the original order.  Never <jk>null</jk>.
331    */
332   public static List<CalendarEvent> retainWellFormed(Collection<CalendarEvent> values) {
333      var out = new ArrayList<CalendarEvent>();
334      if (values != null)
335         for (var e : values)
336            if (e != null && e.malformedReason() == null)
337               out.add(e);
338      return out;
339   }
340
341   /**
342    * Fail-closed shape check for a seed event, reused by {@link CalendarDef#validate()} and by an endpoint handler
343    * that wants to validate its own payload before serialization.
344    *
345    * <p>
346    * Throws for a malformed event (see {@link #malformedReason()}) <b>and</b> for the author errors a seed
347    * definition must not contain: an undeclared {@code categoryId}, a cross-origin {@code href}, and a
348    * {@code start} outside the rendered window.  Callers that want the drop-and-continue posture of the seed and
349    * per-month GET paths use {@link #malformedReason()}/{@link #retainWellFormed(Collection)} instead.
350    *
351    * @param knownCategoryIds The declared category ids; a set {@code categoryId} must be a member.  May be
352    *    <jk>null</jk> (then any {@code categoryId} fails).
353    * @param initialYear The initial year the seed month is rendered for, or <jk>null</jk> to skip the off-month
354    *    check.
355    * @param initialMonth The 1-based initial month, or <jk>null</jk> to skip the off-month check.
356    * @throws IllegalArgumentException If this event is not well-formed.
357    */
358   public void validate(Set<String> knownCategoryIds, Integer initialYear, Integer initialMonth) {
359      var bad = malformedReason();
360      if (bad != null)
361         throw iaex("CalendarEvent '%s' is malformed: %s", id, bad);
362      var startDate = civilStart();
363      if (inb(categoryId)
364            && (knownCategoryIds == null || !knownCategoryIds.contains(categoryId)))
365         throw iaex("CalendarEvent '%s' categoryId '%s' is not a declared category.", id, categoryId);
366      if (inb(href) && !SafePathTemplate.isSafeDocumentUrl(href))
367         throw iaex("CalendarEvent '%s' href must be a same-origin document URL: %s", id, href);
368      if (initialYear != null && initialMonth != null) {
369         // Now that end is layout-significant, "in the rendered month" means the occupied range intersects it,
370         // not that start falls inside it: a span beginning in the previous month is genuinely visible here.
371         var monthStart = LocalDate.of(initialYear, initialMonth, 1);
372         var monthEnd = monthStart.plusMonths(1).minusDays(1);
373         if (lastDay().isBefore(monthStart) || startDate.isAfter(monthEnd))
374            throw iaex("CalendarEvent '%s' start %s is outside the initial month %d-%02d.", id, start, initialYear,
375               initialMonth);
376      }
377   }
378
379   /** Whether {@code end} carries a value at all (an omitted or blank {@code end} means start-only). */
380   private boolean hasEnd() {
381      return inb(end);
382   }
383
384   /** Whether an ISO value is date-only ({@code yyyy-MM-dd}) rather than a date-time. */
385   private static boolean isDateOnly(String value) {
386      return value.length() <= 10;   // only reached with a non-blank end (see hasEnd())
387   }
388
389   /** Parses the civil ({@code yyyy-MM-dd}) portion of an ISO date/date-time; never {@code Date.parse}-style zone shifting. */
390   private LocalDate parseCivil(String value, String field) {
391      var d = civilOrNull(value);
392      if (d == null)
393         throw iaex("CalendarEvent '%s' %s is not a valid ISO date or date-time: %s", id, field, value);
394      return d;
395   }
396
397   /** The civil ({@code yyyy-MM-dd}) portion of an ISO date/date-time, or {@code null} when it is not parseable. */
398   private static LocalDate civilOrNull(String value) {
399      if (value == null || value.length() < 10)
400         return null;
401      if (value.length() > 10 && value.charAt(10) != 'T')
402         return null;
403      try {
404         return LocalDate.parse(value.substring(0, 10), DateTimeFormatter.ISO_LOCAL_DATE);
405      } catch (DateTimeParseException e) {
406         return null;
407      }
408   }
409
410   /**
411    * The local time of an ISO date-time, ignoring any trailing offset/{@code Z}, or {@code null} when the value is
412    * date-only or its time portion is not parseable (fail-soft: an odd time never rejects a shipped event).
413    */
414   private static LocalTime civilTime(String value) {
415      if (value == null || value.length() <= 11 || value.charAt(10) != 'T')
416         return null;
417      var body = value.substring(11);
418      for (var i = 0; i < body.length(); i++) {
419         var c = body.charAt(i);
420         if (c == 'Z' || c == '+' || (c == '-' && i > 0)) {
421            body = body.substring(0, i);
422            break;
423         }
424      }
425      try {
426         return LocalTime.parse(body, DateTimeFormatter.ISO_LOCAL_TIME);
427      } catch (DateTimeParseException e) {
428         return null;
429      }
430   }
431
432   /** {@link #civilTime(String)} with an unparseable/absent time degraded to midnight. */
433   private static LocalTime civilTimeOrMidnight(String value) {
434      var t = civilTime(value);
435      return t == null ? LocalTime.MIDNIGHT : t;
436   }
437}