package internal

import (
	"time"

	"github.com/pkg/errors"
)

// TimeOfDay represents the time of day.
type TimeOfDay struct {
	hour, minute, second int
	d                    time.Duration
}

const shortForm = "15:04:05"

// NewTimeOfDay returns a newly initialized TimeOfDay.
func NewTimeOfDay(hour, minute, second int) TimeOfDay {
	d := time.Duration(second)*time.Second +
		time.Duration(minute)*time.Minute +
		time.Duration(hour)*time.Hour

	return TimeOfDay{hour: hour, minute: minute, second: second, d: d}
}

// ParseTimeOfDay parses a TimeOfDay from a string in the format HH:MM:SS.
func ParseTimeOfDay(str string) (TimeOfDay, error) {
	t, err := time.Parse(shortForm, str)
	if err != nil {
		return TimeOfDay{}, errors.Wrap(err, "time must be in the format HH:MM:SS")
	}

	return NewTimeOfDay(t.Clock()), nil
}

// TimeRange represents a time band in a given time zone.
type TimeRange struct {
	startTime, endTime TimeOfDay
	weekdays           []time.Weekday
	startDay, endDay   *time.Weekday
	loc                *time.Location
}

// NewUTCTimeRange returns a time range in UTC.
func NewUTCTimeRange(start, end TimeOfDay, weekdays []time.Weekday) (*TimeRange, error) {
	return NewTimeRangeInLocation(start, end, weekdays, time.UTC)
}

// NewTimeRangeInLocation returns a time range in a given location.
func NewTimeRangeInLocation(start, end TimeOfDay, weekdays []time.Weekday, loc *time.Location) (*TimeRange, error) {

	if loc == nil {
		return nil, errors.New("time: missing Location in call to NewTimeRangeInLocation")
	}

	return &TimeRange{
		startTime: start,
		endTime:   end,
		weekdays:  weekdays,
		loc:       loc,
	}, nil
}

// NewUTCWeekRange returns a weekly TimeRange.
func NewUTCWeekRange(startTime, endTime TimeOfDay, startDay, endDay time.Weekday) (*TimeRange, error) {
	return NewWeekRangeInLocation(startTime, endTime, startDay, endDay, time.UTC)
}

// NewWeekRangeInLocation returns a time range in a given location.
func NewWeekRangeInLocation(startTime, endTime TimeOfDay, startDay, endDay time.Weekday, loc *time.Location) (*TimeRange, error) {
	r, err := NewTimeRangeInLocation(startTime, endTime, []time.Weekday{}, loc)
	if err != nil {
		return nil, err
	}
	r.startDay = &startDay
	r.endDay = &endDay

	return r, nil
}

func (r *TimeRange) isInWeekdays(day time.Weekday) bool {
	if len(r.weekdays) > 0 {
		found := false

		for _, weekday := range r.weekdays {
			if day == weekday {
				found = true
				break
			}
		}

		if !found {
			return false
		}
	}

	return true
}

func (r *TimeRange) addWeekdayOffset(day time.Weekday, offset int) time.Weekday {
	return (day + time.Weekday(offset)) % 7
}

func (r *TimeRange) isInTimeRange(t time.Time) bool {
	t = t.In(r.loc)
	ts := NewTimeOfDay(t.Clock()).d

	if r.startTime.d < r.endTime.d {
		if r.isInWeekdays(t.Weekday()) {
			return r.startTime.d <= ts && ts <= r.endTime.d
		}

		return false
	}

	if ts <= r.endTime.d {
		return r.isInWeekdays(r.addWeekdayOffset(t.Weekday(), -1))
	}

	if ts >= r.startTime.d {
		return r.isInWeekdays(t.Weekday())
	}

	return false
}

func (r *TimeRange) isInWeekRange(t time.Time) bool {
	t = t.In(r.loc)
	day := t.Weekday()

	if *r.startDay == *r.endDay {
		if day == *r.startDay {
			return r.isInTimeRange(t)
		}

		if r.startTime.d < r.endTime.d {
			return false
		}

		return true
	}

	switch {
	case *r.startDay < *r.endDay:
		if day < *r.startDay || *r.endDay < day {
			return false
		}

	default:
		if *r.endDay < day && day < *r.startDay {
			return false
		}
	}

	timeOfDay := NewTimeOfDay(t.Clock())

	if day == *r.startDay {
		return timeOfDay.d >= r.startTime.d
	}

	if day == *r.endDay {
		return timeOfDay.d <= r.endTime.d
	}

	return true
}

// IsInRange returns true if time t is within in the time range.
func (r *TimeRange) IsInRange(t time.Time) bool {
	if r == nil {
		return true
	}

	if r.startDay != nil {
		return r.isInWeekRange(t)
	}

	return r.isInTimeRange(t)
}

// IsInSameRange determines if two points in time are in the same time range.
func (r *TimeRange) IsInSameRange(t1, t2 time.Time) bool {
	if r == nil {
		return true
	}

	if !(r.IsInRange(t1) && r.IsInRange(t2)) {
		return false
	}

	if t2.Before(t1) {
		t1, t2 = t2, t1
	}

	t1 = t1.In(r.loc)
	t1Time := NewTimeOfDay(t1.Clock())
	dayOffset := 0

	if r.endDay == nil {
		if r.startTime.d >= r.endTime.d && t1Time.d >= r.startTime.d {
			dayOffset = 1
		}
	} else {
		switch {
		case *r.endDay < t1.Weekday():
			dayOffset = 7 + int(*(r.endDay)-t1.Weekday())

		case t1.Weekday() == *r.endDay:
			if r.endTime.d <= t1Time.d {
				dayOffset = 7
			}

		default:
			dayOffset = int(*(r.endDay) - t1.Weekday())
		}
	}

	sessionEnd := time.Date(t1.Year(), t1.Month(), t1.Day(), r.endTime.hour, r.endTime.minute, r.endTime.second, 0, r.loc)
	sessionEnd = sessionEnd.AddDate(0, 0, dayOffset)

	return t2.Before(sessionEnd)
}
