001package com.identityworksllc.iiq.common;
002
003import java.time.DayOfWeek;
004import java.time.LocalDate;
005import java.time.temporal.Temporal;
006import java.time.temporal.TemporalAdjuster;
007import java.util.Calendar;
008import java.util.EnumSet;
009import java.util.HashSet;
010import java.util.Set;
011
012/**
013 * A {@link TemporalAdjuster} that adds or subtracts a specified number of business days to a date.
014 * A business day is a date that is not a weekend or in the list of holidays.
015 *
016 * For example:
017 * {@code}
018 *  LocalDate now = LocalDate.now();
019 *  LocalDate nextBusinessDay = now.with(BusinessDateAdjuster.addWorkDays(1));
020 * {@code}
021 *
022 * You can also use it with a {@link Calendar} object via the static helper methods.
023 *
024 * {@code}
025 * Calendar now = Calendar.getInstance();
026 * Calendar nextBusinessDay = BusinessDateAdjuster.adjustCalendar(now, 1);
027 * {@code}
028 */
029public class BusinessDayAdjuster implements TemporalAdjuster {
030    /**
031     * The default set of weekend days (Saturday and Sunday).
032     */
033    public static final Set<DayOfWeek> DEFAULT_WEEKEND_DAYS = EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY);
034
035    /**
036     * The number of business days to add (positive) or subtract (negative).
037     */
038    private final int days;
039
040    /**
041     * The set of dates to be considered holidays (non-work days).
042     */
043    private final Set<LocalDate> holidays;
044
045    /**
046     * The set of days of the week to be considered part of the weekend.
047     */
048    private final Set<DayOfWeek> weekendDays;
049
050    /**
051     * Creates a new BusinessDayAdjuster with no holidays and the default set of weekend days.
052     * @param days        The number of business days to add (positive) or subtract (negative)
053     */
054    public BusinessDayAdjuster(int days) {
055        this(days, null, DEFAULT_WEEKEND_DAYS);
056    }
057
058    /**
059     * Creates a new BusinessDayAdjuster with the given list of holidays and the default set of weekend days.
060     * @param days        The number of business days to add (positive) or subtract (negative)
061     * @param holidays    A set of dates to be considered holidays (non-work days)
062     */
063    public BusinessDayAdjuster(int days, Set<LocalDate> holidays) {
064        this(days, holidays, DEFAULT_WEEKEND_DAYS);
065    }
066
067    /**
068     * Creates a new BusinessDayAdjuster.
069     *
070     * @param days        The number of business days to add (positive) or subtract (negative)
071     * @param holidays    A set of dates to be considered holidays (non-work days)
072     * @param weekendDays A set of days to be considered weekends
073     */
074    public BusinessDayAdjuster(int days, Set<LocalDate> holidays, Set<DayOfWeek> weekendDays) {
075        if (weekendDays != null && weekendDays.size() == 7) {
076            throw new IllegalArgumentException("Weekend days cannot include all days of the week");
077        }
078        this.days = days;
079        this.holidays = holidays != null ? holidays : new HashSet<>();
080        this.weekendDays = weekendDays != null ? weekendDays : EnumSet.of(DayOfWeek.SATURDAY, DayOfWeek.SUNDAY);
081    }
082
083    /**
084     * Creates a BusinessDayAdjuster that adds the specified number of work days.
085     *
086     * @param days        The number of work days to add
087     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
088     */
089    public static BusinessDayAdjuster addWorkDays(int days) {
090        return new BusinessDayAdjuster(days, null, DEFAULT_WEEKEND_DAYS);
091    }
092
093    /**
094     * Creates a BusinessDayAdjuster that adds the specified number of work days.
095     *
096     * @param days        The number of work days to add
097     * @param holidays    A set of dates to be considered holidays
098     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
099     */
100    public static BusinessDayAdjuster addWorkDays(int days, Set<LocalDate> holidays) {
101        return new BusinessDayAdjuster(days, holidays, DEFAULT_WEEKEND_DAYS);
102    }
103
104    /**
105     * Creates a BusinessDayAdjuster that adds the specified number of work days.
106     *
107     * @param days        The number of work days to add
108     * @param holidays    A set of dates to be considered holidays
109     * @param weekendDays A set of days to be considered weekends
110     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
111     */
112    public static BusinessDayAdjuster addWorkDays(int days, Set<LocalDate> holidays, Set<DayOfWeek> weekendDays) {
113        return new BusinessDayAdjuster(days, holidays, weekendDays);
114    }
115
116    /**
117     * Adjusts the specified calendar by the required number of business days.
118     * The input calendar is not modified.
119     *
120     * @param input The input calendar
121     * @param days The number of business days to add (positive) or subtract (negative)
122     * @return The adjusted calendar, a separate object from the original
123     */
124    public static Calendar adjustCalendar(Calendar input, int days) {
125        return adjustCalendar(input, days, new HashSet<>());
126    }
127
128    /**
129     * Adjusts the specified calendar by the required number of business days.
130     * The input calendar is not modified.
131     *
132     * @param input The input calendar
133     * @param days The number of business days to add (positive) or subtract (negative)
134     * @param holidays A set of dates to be considered holidays
135     * @return The adjusted calendar, a separate object from the original
136     */
137    public static Calendar adjustCalendar(Calendar input, int days, Set<LocalDate> holidays) {
138        return adjustCalendar(input, days, holidays, DEFAULT_WEEKEND_DAYS);
139    }
140
141    /**
142     * Adjusts the specified calendar by the required number of business days.
143     * The input calendar is not modified.
144     *
145     * @param input The input calendar
146     * @param days The number of business days to add (positive) or subtract (negative)
147     * @param holidays A set of dates to be considered holidays
148     * @param weekendDays A set of days to be considered weekends
149     * @return The adjusted calendar, a separate object from the original
150     */
151    public static Calendar adjustCalendar(Calendar input, int days, Set<LocalDate> holidays, Set<DayOfWeek> weekendDays) {
152        // Convert Calendar to LocalDate
153        LocalDate date = LocalDate.of(input.get(Calendar.YEAR), input.get(Calendar.MONTH) + 1, input.get(Calendar.DAY_OF_MONTH));
154
155        // Adjust the LocalDate
156        LocalDate adjustedDate = date.with(BusinessDayAdjuster.addWorkDays(days, holidays, weekendDays));
157
158        // Convert LocalDate back to Calendar
159        Calendar output = Calendar.getInstance();
160        output.set(adjustedDate.getYear(), adjustedDate.getMonthValue() - 1, adjustedDate.getDayOfMonth());
161
162        // Restore the time component of the Calendar
163        output.set(Calendar.HOUR_OF_DAY, input.get(Calendar.HOUR_OF_DAY));
164        output.set(Calendar.MINUTE, input.get(Calendar.MINUTE));
165        output.set(Calendar.SECOND, input.get(Calendar.SECOND));
166        output.set(Calendar.MILLISECOND, input.get(Calendar.MILLISECOND));
167
168        return output;
169    }
170
171    /**
172     * Creates a BusinessDayAdjuster that subtracts the specified number of work days.
173     *
174     * @param days        The number of work days to add
175     * @param holidays    A set of dates to be considered holidays
176     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
177     */
178    public static BusinessDayAdjuster subtractWorkDays(int days, Set<LocalDate> holidays) {
179        return new BusinessDayAdjuster(-Math.abs(days), holidays, DEFAULT_WEEKEND_DAYS);
180    }
181
182    /**
183     * Creates a BusinessDayAdjuster that subtracts the specified number of work days.
184     *
185     * @param days        The number of work days to add
186     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
187     */
188    public static BusinessDayAdjuster subtractWorkDays(int days) {
189        return new BusinessDayAdjuster(-Math.abs(days), null, DEFAULT_WEEKEND_DAYS);
190    }
191
192    /**
193     * Creates a WorkDayAdjuster that subtracts the specified number of work days.
194     *
195     * @param days        The number of work days to subtract
196     * @param holidays    A set of dates to be considered holidays
197     * @param weekendDays A set of days to be considered weekends
198     * @return A BusinessDayAdjuster for use with {@link LocalDate#with(TemporalAdjuster)}
199     */
200    public static BusinessDayAdjuster subtractWorkDays(int days, Set<LocalDate> holidays, Set<DayOfWeek> weekendDays) {
201        return new BusinessDayAdjuster(-Math.abs(days), holidays, weekendDays);
202    }
203
204    /**
205     * Adjusts the specified temporal object by the required number of business days.
206     *
207     * @param temporal  the temporal object to adjust, not null
208     * @return the adjusted temporal object, not null
209     * @throws NullPointerException if the input object is null
210     * @see TemporalAdjuster#adjustInto(Temporal)
211     */
212    @Override
213    public Temporal adjustInto(Temporal temporal) {
214        if (temporal == null) {
215            throw new NullPointerException("temporal is null");
216        }
217
218        LocalDate date = LocalDate.from(temporal);
219
220        // If no adjustment needed, return original date
221        if (days == 0) {
222            return date;
223        }
224
225        // Determine direction (adding or subtracting days)
226        int direction = Integer.signum(days);
227        int absWorkDays = Math.abs(days);
228
229        int weekendDayCount = 7 - this.weekendDays.size();
230
231        // Step 1: Calculate naive target date (ignoring holidays, including weekends)
232        // For every {weekendDayCount} work days, we add 7 calendar days (i.e., a full week)
233        int fullWeeks = absWorkDays / weekendDayCount;
234        int remainingWorkDays = absWorkDays % weekendDayCount;
235
236        // Calculate calendar days from full weeks (7 days per week)
237        int calendarDays = fullWeeks * 7;
238
239        // Add remaining work days, accounting for weekends
240        LocalDate tempDate = date.plusDays(direction * calendarDays);
241        int addedWorkDays = 0;
242
243        while (addedWorkDays < remainingWorkDays) {
244            tempDate = tempDate.plusDays(direction);
245            DayOfWeek dayOfWeek = tempDate.getDayOfWeek();
246            if (!weekendDays.contains(dayOfWeek)) {
247                addedWorkDays++;
248            }
249        }
250
251        while (weekendDays.contains(tempDate.getDayOfWeek())) {
252            tempDate = tempDate.plusDays(direction);
253        }
254
255        // Step 2: Adjust for holidays
256        // Get all holidays that fall on work days between start and end dates
257        LocalDate startDate = direction > 0 ? date : tempDate;
258        LocalDate endDate = direction > 0 ? tempDate : date;
259
260        long holidaysInRange = holidays.stream()
261                .filter(holiday -> !holiday.isBefore(startDate) && !holiday.isAfter(endDate))
262                .filter(holiday -> {
263                    DayOfWeek dow = holiday.getDayOfWeek();
264                    return !weekendDays.contains(dow);
265                })
266                .count();
267
268        // Add or subtract extra days to account for holidays
269        if (holidaysInRange > 0) {
270            for (int i = 0; i < holidaysInRange; i++) {
271                // For each holiday, add days in the specified direction
272                // until we find a non-holiday work day.
273                while (true) {
274                    tempDate = tempDate.plusDays(direction);
275                    DayOfWeek dayOfWeek = tempDate.getDayOfWeek();
276
277                    // Skip weekends
278                    if (weekendDays.contains(dayOfWeek)) {
279                        continue;
280                    }
281
282                    // Skip if this additional day is also a holiday
283                    if (holidays.contains(tempDate)) {
284                        continue;
285                    }
286
287                    break;
288                }
289            }
290        }
291
292        return tempDate;
293    }
294}