1// SPDX-License-Identifier: ISC
2// Copyright (c) 2014-2020 Bitmark Inc.
3// Use of this source code is governed by an ISC
4// license that can be found in the LICENSE file.
5
6package main
7
8import (
9	"fmt"
10	"reflect"
11	"sort"
12	"strconv"
13	"strings"
14	"time"
15	"unicode"
16
17	"github.com/bitmark-inc/logger"
18)
19
20const (
21	defaultNum                = 0
22	defaultTimeStrErrorMsg    = "clock time out of range"
23	defaultTimePeriodErrorMsg = "time period format error"
24	timePeriodSeparator       = ","
25	clockSeparator            = "-"
26	spaceChar                 = " "
27	allDayClockStr            = ""
28	defaultIndex              = 0
29	oneWeekDuration           = time.Duration(24*7) * time.Hour
30	delayOfStartStop          = time.Duration(5) * time.Second
31	jobCalendarPrefix         = "calendar"
32)
33
34type JobCalendar interface {
35	PickNextStartEvent(time.Time) interface{}
36	PickNextStopEvent(time.Time) interface{}
37	PickInitialiseStartEvent(time.Time) interface{}
38	PickInitialiseStopEvent(time.Time) interface{}
39	Refresh(calendar ConfigCalendar)
40	RescheduleStartEventsPrior(event time.Time)
41	RescheduleStopEventsPrior(event time.Time)
42	RunForever() bool
43	SetLog(l *logger.L)
44}
45
46type NumberRange struct {
47	min uint32
48	max uint32
49}
50
51// collapse events, all start time put in one place, all end time put in one place
52type FlattenEvents struct {
53	start []time.Time
54	stop  []time.Time
55}
56
57type SingleEvent struct {
58	start time.Time
59	stop  time.Time
60}
61
62type JobCalendarData struct {
63	flattenEvents     FlattenEvents
64	events            map[time.Weekday][]SingleEvent
65	rawData           ConfigCalendar
66	rescheduleChannel chan<- struct{}
67	log               *logger.L
68}
69
70type TimeData struct {
71	hour, minute uint32
72}
73
74func newJobCalendar(channel chan<- struct{}) JobCalendar {
75	return &JobCalendarData{
76		flattenEvents: FlattenEvents{
77			start: []time.Time{},
78			stop:  []time.Time{},
79		},
80		events: map[time.Weekday][]SingleEvent{
81			time.Sunday:    {},
82			time.Monday:    {},
83			time.Tuesday:   {},
84			time.Wednesday: {},
85			time.Thursday:  {},
86			time.Friday:    {},
87			time.Saturday:  {},
88		},
89		rawData:           ConfigCalendar{},
90		rescheduleChannel: channel,
91	}
92}
93
94func (j *JobCalendarData) newEmptyFlattenEvents() FlattenEvents {
95	return FlattenEvents{
96		start: []time.Time{},
97		stop:  []time.Time{},
98	}
99}
100
101func (j *JobCalendarData) newEmptyEvents() map[time.Weekday][]SingleEvent {
102	return map[time.Weekday][]SingleEvent{
103		time.Sunday:    {},
104		time.Monday:    {},
105		time.Tuesday:   {},
106		time.Wednesday: {},
107		time.Thursday:  {},
108		time.Friday:    {},
109		time.Saturday:  {},
110	}
111}
112
113func (j *JobCalendarData) SetLog(l *logger.L) {
114	j.log = l
115}
116
117func (j *JobCalendarData) RunForever() bool {
118	return 0 == len(j.flattenEvents.stop)
119}
120
121func (j *JobCalendarData) Refresh(calendar ConfigCalendar) {
122	j.log.Debug("refresh calendar")
123	if !j.isSameCalendar(calendar) {
124		j.log.Debug("calendar change")
125		j.setNewCalendar(calendar)
126		j.resetEvents()
127		j.parseRawData(calendar)
128		j.removeRedundantStopEvent()
129		j.printEvents()
130		j.notifyJobManager()
131	}
132}
133
134func (j *JobCalendarData) resetEvents() {
135	j.flattenEvents = j.newEmptyFlattenEvents()
136	j.events = j.newEmptyEvents()
137}
138
139func (j *JobCalendarData) notifyJobManager() {
140	j.log.Debug("notify manager for new calendar settings...")
141	j.rescheduleChannel <- struct{}{}
142}
143
144func (j *JobCalendarData) setNewCalendar(calendar ConfigCalendar) {
145	j.rawData = calendar
146}
147
148func (j *JobCalendarData) parseRawData(calendar ConfigCalendar) {
149	j.convertWeekScheduleToEvents()
150	j.sortFlattenEventsFromEarlier2Later()
151}
152
153func (j *JobCalendarData) removeRedundantStopEvent() {
154	j.log.Debug("removing redundant events...")
155	start := j.flattenEvents.start
156	stop := j.flattenEvents.stop
157	redundantIdx := make([]bool, len(j.flattenEvents.stop))
158
159loop:
160	for i, k := 0, 0; i < len(start) && k < len(stop); {
161		if start[i].Equal(stop[k]) {
162			j.log.Debugf("%+v stop event is redundant", j.flattenEvents.stop[k])
163			redundantIdx[k] = true
164			i++
165			k++
166			continue loop
167		}
168		if start[i].After(stop[k]) {
169			k++
170		} else {
171			i++
172		}
173	}
174	newSlice := make([]time.Time, 0, len(j.flattenEvents.stop))
175	for i := 0; i < len(redundantIdx); i++ {
176		if !redundantIdx[i] {
177			newSlice = append(newSlice, stop[i])
178		}
179	}
180	j.flattenEvents.stop = newSlice
181}
182
183func isEventAlreadyExist(times []time.Time, event time.Time) (bool, int) {
184	if 0 == len(times) {
185		return false, defaultIndex
186	}
187	for i, v := range times {
188		if v == event {
189			return true, i
190		}
191	}
192	return false, defaultIndex
193}
194
195func isSameTime(t1 TimeData, t2 TimeData) bool {
196	if t1.hour == t2.hour && t1.minute == t2.minute {
197		return true
198	}
199	return false
200}
201
202func isTimeDataFirstEarlierThanSecond(first TimeData, second TimeData) bool {
203	if first.hour < second.hour {
204		return true
205	}
206	if first.minute < second.minute {
207		return true
208	}
209	return false
210}
211
212func (j *JobCalendarData) isTimeBooked(event time.Time) bool {
213	weekDay := event.Weekday()
214	events := j.events[weekDay]
215
216	for _, t := range events {
217		afterOrEqualToStartTime := t.start.Before(event) || t.start.Equal(event)
218		beforeOrEqualToEndTime := t.stop.After(event) || t.stop.Equal(event)
219
220		if afterOrEqualToStartTime && beforeOrEqualToEndTime {
221			return true
222		}
223
224		if events[0].stop.IsZero() && afterOrEqualToStartTime {
225			return true
226		}
227	}
228	return false
229}
230
231func (j *JobCalendarData) PickInitialiseStartEvent(event time.Time) interface{} {
232	if j.isTimeBooked(event) {
233		j.log.Debugf("working time, start after %s", delayOfStartStop.String())
234		return event.Add(delayOfStartStop)
235	}
236	return j.PickNextStartEvent(event)
237}
238
239func (j *JobCalendarData) PickInitialiseStopEvent(event time.Time) interface{} {
240	if j.isTimeBooked(event) {
241		return j.PickNextStopEvent(event)
242	}
243	j.log.Debugf("not working time, stop after %s", delayOfStartStop.String())
244	return event.Add(delayOfStartStop)
245}
246
247func (j *JobCalendarData) PickNextStartEvent(event time.Time) interface{} {
248	for _, e := range j.flattenEvents.start {
249		if e.After(event) {
250			j.log.Infof("next start event at %s", e)
251			return e
252		}
253	}
254	j.log.Error("cannot find next start event")
255	j.printEvents()
256	return nil
257}
258
259func (j *JobCalendarData) PickNextStopEvent(event time.Time) interface{} {
260	for _, e := range j.flattenEvents.stop {
261		if e.After(event) {
262			j.log.Infof("next stop event at %s", e)
263			return e
264		}
265	}
266	j.log.Info("cannot find next stop event")
267	j.printEvents()
268	return nil
269}
270
271func (j *JobCalendarData) RescheduleStartEventsPrior(event time.Time) {
272	if 0 == len(j.flattenEvents.start) || j.flattenEvents.start[0].After(event) {
273		return
274	}
275	times := j.flattenEvents.start
276	newSlices := make([]time.Time, 0, len(times))
277	schedules := make([]time.Time, 0, len(times))
278loop:
279	for i, t := range times {
280		if t.Before(event) || t.Equal(event) {
281			schedules = append(schedules, t.Add(oneWeekDuration))
282		} else {
283			newSlices = append(newSlices, times[i:]...)
284			break loop
285		}
286	}
287	newSlices = append(newSlices, schedules...)
288	j.flattenEvents.start = newSlices
289}
290
291func (j *JobCalendarData) RescheduleStopEventsPrior(event time.Time) {
292	if 0 == len(j.flattenEvents.stop) || j.flattenEvents.stop[0].After(event) {
293		return
294	}
295	times := j.flattenEvents.stop
296	newSlices := make([]time.Time, 0, len(times))
297	schedules := make([]time.Time, 0, len(times))
298loop:
299	for i, t := range times {
300		if t.Before(event) || t.Equal(event) {
301			schedules = append(schedules, t.Add(oneWeekDuration))
302		} else {
303			newSlices = append(newSlices, times[i:]...)
304			break loop
305		}
306	}
307	newSlices = append(newSlices, schedules...)
308	j.flattenEvents.stop = newSlices
309}
310
311func (j *JobCalendarData) removeEventFrom(times []time.Time, event time.Time) ([]time.Time, error) {
312	exist, idx := isEventAlreadyExist(times, event)
313	if !exist {
314		return times, nil
315	}
316	return append(times[:idx], times[idx+1:]...), nil
317}
318
319func (j *JobCalendarData) weekDayCurrent2Target(current time.Weekday, target time.Weekday) int {
320	return int(target) - int(current)
321}
322
323func (j *JobCalendarData) parseClockStr(clock string) (TimeData, error) {
324	if clock == "24:00" || clock == "24:0" {
325		return TimeData{
326			hour:   uint32(24),
327			minute: uint32(0),
328		}, nil
329	}
330
331	t, err := time.Parse("15:04", clock)
332	if nil != err {
333		j.log.Errorf("%s\n", err.Error())
334		return TimeData{}, err
335	}
336	return TimeData{
337		hour:   uint32(t.Hour()),
338		minute: uint32(t.Minute()),
339	}, nil
340}
341
342func (j *JobCalendarData) convertStr2NumberWithLimit(str string, numRange NumberRange) (uint32, error) {
343	num, err := strconv.Atoi(str)
344	if err != nil || uint32(num) < numRange.min || uint32(num) > numRange.max {
345		return defaultNum, fmt.Errorf(defaultTimeStrErrorMsg)
346	}
347	return uint32(num), nil
348}
349
350// period: 2:12 - 3:14
351// clock: 2:12
352func (j *JobCalendarData) parseTimePeriod(period string) (TimeData, TimeData, error) {
353	str := strings.ReplaceAll(period, spaceChar, "")
354	clocks := strings.Split(str, clockSeparator)
355	if len(clocks) > 2 {
356		return TimeData{}, TimeData{}, fmt.Errorf(defaultTimePeriodErrorMsg)
357	}
358	timeFirst, err := j.parseClockStr(clocks[0])
359	if nil != err {
360		return TimeData{}, TimeData{}, fmt.Errorf(defaultTimePeriodErrorMsg)
361	}
362	timeSecond, err := j.parseClockStr(clocks[1])
363	if nil != err {
364		return TimeData{}, TimeData{}, fmt.Errorf(defaultTimePeriodErrorMsg)
365	}
366
367	if isTimeDataFirstEarlierThanSecond(timeFirst, timeSecond) {
368		return timeFirst, timeSecond, nil
369	}
370
371	return timeSecond, timeFirst, nil
372}
373
374func (j *JobCalendarData) timeByWeekdayAndOffset(day time.Weekday, clock TimeData) time.Time {
375	now := time.Now()
376	dayDiffNum := j.weekDayCurrent2Target(now.Weekday(), day)
377	return time.Date(now.Year(), now.Month(), now.Day()+dayDiffNum,
378		int(clock.hour), int(clock.minute), 0, 0, now.Location())
379}
380
381func (j *JobCalendarData) timeOfWeekdayStartFromBeginning(day time.Weekday) FlattenEvents {
382	flattenEvents := FlattenEvents{
383		start: []time.Time{j.timeByWeekdayAndOffset(day, TimeData{hour: 0, minute: 0})},
384		stop:  []time.Time{},
385	}
386	return flattenEvents
387}
388
389func (j *JobCalendarData) sortFlattenEventsFromEarlier2Later() {
390	j.log.Debug("sort events")
391	events := j.flattenEvents
392	sort.Slice(events.start, func(i, j int) bool {
393		return events.start[i].Before(events.start[j])
394	})
395	sort.Slice(events.stop, func(i, j int) bool {
396		return events.stop[i].Before(events.stop[j])
397	})
398}
399
400// TODO: refactor to use for loop, currently no idea how to use code for
401// time.Sunday & time.rawData.Sunday
402func (j *JobCalendarData) convertWeekScheduleToEvents() {
403	j.convertDayScheduleToEvents(time.Sunday, j.rawData.Sunday)
404	j.convertDayScheduleToEvents(time.Monday, j.rawData.Monday)
405	j.convertDayScheduleToEvents(time.Tuesday, j.rawData.Tuesday)
406	j.convertDayScheduleToEvents(time.Wednesday, j.rawData.Wednesday)
407	j.convertDayScheduleToEvents(time.Thursday, j.rawData.Thursday)
408	j.convertDayScheduleToEvents(time.Friday, j.rawData.Friday)
409	j.convertDayScheduleToEvents(time.Saturday, j.rawData.Saturday)
410}
411
412func (j *JobCalendarData) convertDayScheduleToEvents(day time.Weekday, clock string) {
413	if allDayClockStr == strings.Trim(clock, spaceChar) {
414		j.log.Debugf("%s work all day", day.String())
415		j.scheduleStartEventWhenDayBegin(day)
416		return
417	}
418	j.scheduleEvents(day, clock)
419}
420
421func containsLetter(s string) bool {
422	for _, c := range s {
423		if unicode.IsLetter(c) {
424			return true
425		}
426	}
427	return false
428}
429
430func (j *JobCalendarData) isValidPeriod(str string) bool {
431	s := strings.Split(str, clockSeparator)
432	if len(s) != 2 {
433		j.log.Errorf("invalid caledar string %s, contains too many clock string", str)
434		return false
435	}
436	t1 := strings.Trim(s[0], spaceChar)
437	t2 := strings.Trim(s[1], spaceChar)
438	if t1 == t2 {
439		j.log.Errorf("invalid caledar string %s, 2 clock strings equal", str)
440		return false
441	}
442
443	if containsLetter(t1) || containsLetter(t2) {
444		j.log.Errorf("invalid caledar string %s, contains letter", str)
445		return false
446	}
447
448	return true
449}
450
451func (j *JobCalendarData) scheduleEvents(day time.Weekday, clock string) {
452	periods := strings.Split(clock, timePeriodSeparator)
453	events := make([]SingleEvent, 0)
454	flattenEvents := FlattenEvents{
455		start: []time.Time{},
456		stop:  []time.Time{},
457	}
458
459loop:
460	for _, period := range periods {
461		if !j.isValidPeriod(period) {
462			continue loop
463		}
464		t1, t2, err := j.parseTimePeriod(period)
465		if nil != err {
466			j.log.Errorf("error parse time period %s, error: %s", period, err)
467			continue loop
468		}
469		events = append(events, SingleEvent{
470			start: j.timeByWeekdayAndOffset(day, t1),
471			stop:  j.timeByWeekdayAndOffset(day, t2),
472		})
473		flattenEvents.start = append(
474			flattenEvents.start,
475			j.timeByWeekdayAndOffset(day, t1),
476		)
477		flattenEvents.stop = append(
478			flattenEvents.stop,
479			j.timeByWeekdayAndOffset(day, t2),
480		)
481	}
482
483	if 0 == len(flattenEvents.start) {
484		j.log.Debugf("empty flatten start event, add start event to day start")
485		j.scheduleStartEventWhenDayBegin(day)
486		return
487	}
488
489	j.events[day] = events
490	j.flattenEvents.start = append(j.flattenEvents.start, flattenEvents.start...)
491	j.flattenEvents.stop = append(j.flattenEvents.stop, flattenEvents.stop...)
492}
493
494func (j *JobCalendarData) scheduleStartEventWhenDayBegin(day time.Weekday) {
495	flattenEvent := j.timeOfWeekdayStartFromBeginning(day)
496	j.flattenEvents.start = append(j.flattenEvents.start, flattenEvent.start[0])
497	j.events[day] = []SingleEvent{
498		{start: flattenEvent.start[0]},
499	}
500}
501
502func (j *JobCalendarData) isSameCalendar(new ConfigCalendar) bool {
503	equal := reflect.DeepEqual(j.rawData, new)
504	if !equal {
505		j.log.Debug("config changed")
506		j.log.Debugf("previous: %+v", j.rawData)
507		j.log.Debugf("new: %+v", new)
508	}
509	return equal
510}
511
512func (j *JobCalendarData) printEvents() {
513	weekdays := []time.Weekday{
514		time.Sunday,
515		time.Monday,
516		time.Tuesday,
517		time.Wednesday,
518		time.Thursday,
519		time.Friday,
520		time.Saturday,
521	}
522
523	for _, d := range weekdays {
524		j.log.Debugf("%d start flattenEvents: %+v",
525			len(j.flattenEvents.start),
526			j.flattenEvents.start,
527		)
528		j.log.Debugf("%d stop flattenEvents: %+v",
529			len(j.flattenEvents.stop),
530			j.flattenEvents.stop,
531		)
532		j.log.Debugf("%d events on %s", len(j.events[d]), d.String())
533		for _, e := range j.events[d] {
534			if e.stop.IsZero() {
535				j.log.Debugf("%s: start: %s",
536					d.String(),
537					e.start,
538				)
539			} else {
540				j.log.Debugf("%s: start: %s, end: %s",
541					d.String(),
542					e.start,
543					e.stop,
544				)
545			}
546		}
547	}
548}
549