import { Utils } from "../../Utils"
import opening_hours from "opening_hours"
import { Store } from "../../Logic/UIEventSource"
import { Translation, TypedTranslation } from "../i18n/Translation"
import Translations from "../i18n/Translations"

export interface OpeningHour {
    weekday: number // 0 is monday, 1 is tuesday, ...
    startHour: number
    startMinutes: number
    endHour: number
    endMinutes: number
}

export interface OpeningRange {
    isOpen: boolean
    isSpecial: boolean
    comment: string
    startDate: Date
    endDate: Date
}

/**
 * Various utilities manipulating opening hours
 */
export class OH {
    private static readonly days = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
    private static readonly daysIndexed = {
        mo: 0,
        tu: 1,
        we: 2,
        th: 3,
        fr: 4,
        sa: 5,
        su: 6,
    }

    public static hhmm(h: number, m: number): string {
        if (h == 24) {
            return "00:00"
        }
        return Utils.TwoDigits(h) + ":" + Utils.TwoDigits(m)
    }

    /**
     * const rules = [{weekday: 6,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0},
     *                {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}]
     * OH.ToString(rules) // =>  "Tu 10:00-12:00; Su 13:00-17:00"
     *
     * const rules = [{weekday: 3,endHour: 17,endMinutes: 0,startHour: 13,startMinutes: 0}, {weekday: 1,endHour: 12,endMinutes: 0,startHour: 10,startMinutes: 0}]
     * OH.ToString(rules) // => "Tu 10:00-12:00; Th 13:00-17:00"
     *
     * const rules = [ { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }]);
     * OH.ToString(rules) // => "Tu 10:00-12:00, 13:00-17:00"
     *
     * const rules = [ { weekday: 0, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }, { weekday: 0, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0}, { weekday: 1, endHour: 17, endMinutes: 0, startHour: 13, startMinutes: 0 }, { weekday: 1, endHour: 12, endMinutes: 0, startHour: 10, startMinutes: 0 }];
     * OH.ToString(rules) // => "Mo-Tu 10:00-12:00, 13:00-17:00"
     *
     * // should merge overlapping opening hours
     * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
     * const touchingTimeRange = {  weekday: 1, endHour: 0, endMinutes: 0, startHour: 23, startMinutes: 30 }
     * OH.ToString(OH.MergeTimes([timerange0, touchingTimeRange])) // => "Tu 23:00-00:00"
     *
     * // should merge touching opening hours
     * const timerange0 = {weekday: 1, endHour: 23, endMinutes: 30, startHour: 23, startMinutes: 0 }
     * const overlappingTimeRange =  { weekday: 1, endHour: 24, endMinutes: 0, startHour: 23, startMinutes: 30 }
     * OH.ToString(OH.MergeTimes([timerange0, overlappingTimeRange])) // => "Tu 23:00-00:00"
     *
     */

    public static ToString(ohs: OpeningHour[]) {
        if (ohs.length == 0) {
            return ""
        }
        const partsPerWeekday: string[][] = [[], [], [], [], [], [], []]

        for (const oh of ohs) {
            partsPerWeekday[oh.weekday].push(
                OH.hhmm(oh.startHour, oh.startMinutes) + "-" + OH.hhmm(oh.endHour, oh.endMinutes)
            )
        }

        const stringPerWeekday = partsPerWeekday.map((parts) => parts.sort().join(", "))

        const rules: string[] = []

        let rangeStart = 0
        let rangeEnd = 0

        function pushRule() {
            const rule = stringPerWeekday[rangeStart]
            if (rule === "") {
                return
            }
            if (rangeStart == rangeEnd - 1) {
                rules.push(`${OH.days[rangeStart]} ${rule}`)
            } else {
                rules.push(`${OH.days[rangeStart]}-${OH.days[rangeEnd - 1]} ${rule}`)
            }
        }

        for (; rangeEnd < 7; rangeEnd++) {
            if (stringPerWeekday[rangeStart] != stringPerWeekday[rangeEnd]) {
                pushRule()
                rangeStart = rangeEnd
            }
        }
        pushRule()

        if (rules.length === 1) {
            const rule = rules[0]
            if (rule === "Mo-Su 00:00-00:00") {
                return "24/7"
            }
            if (rule.startsWith("Mo-Su ")) {
                return rule.substring("Mo-Su ".length)
            }
        }

        return rules.join("; ")
    }

    /**
     * Merge duplicate opening-hour element in place.
     * Returns true if something changed
     *
     * // should merge overlapping opening hours
     * const oh1: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 11, endMinutes: 0 };
     * const oh0: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 30, endHour: 12, endMinutes: 0 };
     * OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 12, endMinutes: 0 }]
     *
     * // should merge touching opening hours
     * const oh1: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 11, endMinutes: 0 };
     * const oh0: OpeningHour = { weekday: 0, startHour: 11, startMinutes: 0, endHour: 12, endMinutes: 0 };
     * OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 12, endMinutes: 0 }]
     *
     * // should merge touching opening hours spanning days
     * const oh0: OpeningHour = { weekday: 0, startHour: 10, startMinutes: 0, endHour: 24, endMinutes: 0 };
     * const oh1: OpeningHour = { weekday: 1, startHour: 0, startMinutes: 0, endHour: 12, endMinutes: 0 };
     * OH.MergeTimes([oh0, oh1]) // => [{ weekday: 0, startHour: 10, startMinutes: 0, endHour: 24, endMinutes: 0 }, { weekday: 1, startHour: 0, startMinutes: 0, endHour: 12, endMinutes: 0 }]
     */
    public static MergeTimes(ohs: OpeningHour[]): OpeningHour[] {
        const queue = ohs.map((oh) => {
            if (oh.endHour === 0 && oh.endMinutes === 0) {
                const newOh = {
                    ...oh,
                }
                newOh.endHour = 24
                return newOh
            }
            return oh
        })
        const newList = []
        while (queue.length > 0) {
            const maybeAdd = queue.pop()

            let doAddEntry = true
            if (maybeAdd.weekday == undefined) {
                doAddEntry = false
            }

            for (let i = newList.length - 1; i >= 0 && doAddEntry; i--) {
                const guard = newList[i]
                if (maybeAdd.weekday != guard.weekday) {
                    // Not the same day
                    continue
                }

                if (
                    OH.startTimeLiesInRange(maybeAdd, guard) &&
                    OH.endTimeLiesInRange(maybeAdd, guard)
                ) {
                    // Guard fully covers 'maybeAdd': we can safely ignore maybeAdd
                    doAddEntry = false
                    break
                }

                if (
                    OH.startTimeLiesInRange(guard, maybeAdd) &&
                    OH.endTimeLiesInRange(guard, maybeAdd)
                ) {
                    // 'maybeAdd'  fully covers Guard - the guard is killed
                    newList.splice(i, 1)
                    break
                }

                if (
                    OH.startTimeLiesInRange(maybeAdd, guard) ||
                    OH.endTimeLiesInRange(maybeAdd, guard) ||
                    OH.startTimeLiesInRange(guard, maybeAdd) ||
                    OH.endTimeLiesInRange(guard, maybeAdd)
                ) {
                    // At this point, the maybeAdd overlaps the guard: we should extend the guard and retest it
                    newList.splice(i, 1)
                    let startHour = guard.startHour
                    let startMinutes = guard.startMinutes
                    if (OH.startTime(maybeAdd) < OH.startTime(guard)) {
                        startHour = maybeAdd.startHour
                        startMinutes = maybeAdd.startMinutes
                    }

                    let endHour = guard.endHour
                    let endMinutes = guard.endMinutes
                    if (OH.endTime(maybeAdd) > OH.endTime(guard)) {
                        endHour = maybeAdd.endHour
                        endMinutes = maybeAdd.endMinutes
                    }

                    queue.push({
                        startHour: startHour,
                        startMinutes: startMinutes,
                        endHour: endHour,
                        endMinutes: endMinutes,
                        weekday: guard.weekday,
                    })

                    doAddEntry = false
                    break
                }
            }
            if (doAddEntry) {
                newList.push(maybeAdd)
            }
        }

        // New list can only differ from the old list by merging entries
        // This means that the list is changed only if the lengths are different.
        // If the lengths are the same, we might just as well return the old list and be a bit more stable
        if (newList.length !== ohs.length) {
            newList.sort((a, b) => b.weekday - a.weekday)
            return newList
        } else {
            return ohs
        }
    }

    /**
     * Gives the number of hours since the start of day.
     *
     * // OH.startTime({startHour: 9, startMinutes: 15}) // => 9.25
     */
    public static startTime(oh: OpeningHour): number {
        return oh.startHour + oh.startMinutes / 60
    }

    public static endTime(oh: OpeningHour): number {
        return oh.endHour + oh.endMinutes / 60
    }

    public static startTimeLiesInRange(checked: OpeningHour, mightLieIn: OpeningHour) {
        return (
            OH.startTime(mightLieIn) <= OH.startTime(checked) &&
            OH.startTime(checked) <= OH.endTime(mightLieIn)
        )
    }

    public static endTimeLiesInRange(checked: OpeningHour, mightLieIn: OpeningHour) {
        return (
            OH.startTime(mightLieIn) <= OH.endTime(checked) &&
            OH.endTime(checked) <= OH.endTime(mightLieIn)
        )
    }

    public static parseHHMMRange(hhmmhhmm: string): {
        startHour: number
        startMinutes: number
        endHour: number
        endMinutes: number
    } {
        if (hhmmhhmm == "off") {
            return null
        }

        const timings = hhmmhhmm.split("-")
        const start = OH.parseHHMM(timings[0])
        const end = OH.parseHHMM(timings[1])
        return {
            startHour: start.hours,
            startMinutes: start.minutes,
            endHour: end.hours,
            endMinutes: end.minutes,
        }
    }

    /**
     * Converts an OH-syntax rule into an object
     *
     *
     * const rules = OH.ParsePHRule("PH 12:00-17:00")
     * rules.mode // => " "
     * rules.start // => "12:00"
     * rules.end // => "17:00"
     *
     * OH.ParseRule("PH 12:00-17:00") // => null
     * OH.ParseRule("Th[-1] off") // => null
     *
     * const rules = OH.Parse("24/7");
     * rules.length // => 7
     * rules[0].startHour // => 0
     * OH.ToString(rules) // => "24/7"
     *
     * const rules = OH.ParseRule("11:00-19:00");
     * rules.length // => 7
     * rules[0].weekday // => 0
     * rules[0].startHour // => 11
     * rules[3].endHour // => 19
     *
     * const rules = OH.ParseRule("Mo-Th 11:00-19:00");
     * rules.length // => 4
     * rules[0].weekday // => 0
     * rules[0].startHour // => 11
     * rules[3].endHour // => 19
     *
     * const rules = OH.ParseRule("Mo 20:00-02:00");
     * rules.length // => 2
     * rules[0].weekday // => 0
     * rules[0].startHour // => 20
     * rules[0].endHour // => 0
     * rules[1].weekday // => 1
     * rules[1].startHour // => 0
     * rules[1].endHour // => 2
     *
     * const rules = OH.ParseRule("Mo 00:00-24:00")
     * rules.length // => 1
     * rules[0].weekday // => 0
     * rules[0].startHour // => 0
     * rules[0].endHour // => 24
     */
    public static ParseRule(rule: string): OpeningHour[] {
        try {
            if (rule.trim() == "24/7") {
                return OH.multiply(
                    [0, 1, 2, 3, 4, 5, 6],
                    [
                        {
                            startHour: 0,
                            startMinutes: 0,
                            endHour: 24,
                            endMinutes: 0,
                        },
                    ]
                )
            }

            const split = rule.trim().replace(/, */g, ",").split(" ")
            if (split.length == 1) {
                // First, try to parse this rule as a rule without weekdays
                const timeranges = OH.ParseHhmmRanges(rule)
                const weekdays = [0, 1, 2, 3, 4, 5, 6]
                return OH.multiply(weekdays, timeranges)
            }

            if (split.length == 2) {
                const weekdays = OH.ParseWeekdayRanges(split[0])
                const timeranges = OH.ParseHhmmRanges(split[1])
                return OH.multiply(weekdays, timeranges)
            }
            return []
        } catch (e) {
            console.log("Could not parse weekday rule ", rule)
            return []
        }
    }

    /**
     *
     * OH.ParsePHRule("PH Off") // => {mode: "off"}
     * OH.ParsePHRule("PH OPEN") // => {mode: "open"}
     * OH.ParsePHRule("PH 10:00-12:00") // => {mode: " ", start: "10:00", end: "12:00"}
     * OH.ParsePHRule(undefined) // => null
     * OH.ParsePHRule(null) // => null
     * OH.ParsePHRule("some random string") // => null
     */
    public static ParsePHRule(str: string): {
        mode: string
        start?: string
        end?: string
    } {
        if (str === undefined || str === null) {
            return null
        }
        str = str.trim()
        if (!str.startsWith("PH")) {
            return null
        }

        str = str.trim()
        if (str.toLowerCase() === "ph off") {
            return {
                mode: "off",
            }
        }

        if (str.toLowerCase() === "ph open") {
            return {
                mode: "open",
            }
        }

        if (!str.startsWith("PH ")) {
            return null
        }
        try {
            const timerange = OH.parseHHMMRange(str.substring(2))
            if (timerange === null) {
                return null
            }

            return {
                mode: " ",
                start: OH.hhmm(timerange.startHour, timerange.startMinutes),
                end: OH.hhmm(timerange.endHour, timerange.endMinutes),
            }
        } catch (e) {
            return null
        }
    }

    public static simplify(str: string): string {
        return OH.ToString(OH.MergeTimes(OH.Parse(str)))
    }

    /**
     * Parses a string into Opening Hours
     */
    public static Parse(rules: string): OpeningHour[] {
        if (rules === undefined || rules === "") {
            return []
        }

        const ohs: OpeningHour[] = []

        const split = rules.split(";")

        for (const rule of split) {
            if (rule === "") {
                continue
            }
            try {
                const parsed = OH.ParseRule(rule)
                if (parsed !== null) {
                    ohs.push(...parsed)
                }
            } catch (e) {
                console.error("Could not parse ", rule, ": ", e)
            }
        }

        return ohs
    }

    /**
This function converts a number of ranges (generated by OpeningHours.js) into all the hours of day that a change occurs.
E.g.
Monday, some business is opened from 9:00 till 17:00
Tuesday from 9:30 till 18:00
Wednesday from 9:30 till 12:30
This function will extract all those moments of change and will return 9:00, 9:30, 12:30, 17:00 and 18:00
This list will be sorted

const startDate = new Date(2025,4,20,10,0,0)
const endDate = new Date(2025,4,20,17,0,0)
const changes = OH.allChangeMoments([[{isOpen: true, isSpecial: false, comment: "", startDate, endDate}]])
changes // => [[36000,61200], ["10:00", "17:00"]]
*/
    public static allChangeMoments(
        ranges: {
            isOpen: boolean
            isSpecial: boolean
            comment: string
            startDate: Date
            endDate: Date
        }[][]
    ): [number[], string[]] {
        const changeHours: number[] = []
        const changeHourText: string[] = []

        const extrachangeHours: number[] = []
        const extrachangeHourText: string[] = []

        for (const weekday of ranges) {
            for (const range of weekday) {
                if (!range.isOpen && !range.isSpecial) {
                    continue
                }
                const startOfDay: Date = new Date(range.startDate)
                startOfDay.setHours(0, 0, 0, 0)

                // The number of seconds since the start of the day
                const changeMoment: number =
                    (range.startDate.getTime() - startOfDay.getTime()) / 1000
                if (changeHours.indexOf(changeMoment) < 0) {
                    changeHours.push(changeMoment)
                    changeHourText.push(
                        OH.hhmm(range.startDate.getHours(), range.startDate.getMinutes())
                    )
                }

                // The number of seconds till between the start of the day and closing
                const changeMomentEnd: number =
                    (range.endDate.getTime() - startOfDay.getTime()) / 1000
                if (changeMomentEnd >= 24 * 60 * 60) {
                    if (extrachangeHours.indexOf(changeMomentEnd) < 0) {
                        extrachangeHours.push(changeMomentEnd)
                        extrachangeHourText.push(
                            OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())
                        )
                    }
                } else if (changeHours.indexOf(changeMomentEnd) < 0) {
                    changeHours.push(changeMomentEnd)
                    changeHourText.push(
                        OH.hhmm(range.endDate.getHours(), range.endDate.getMinutes())
                    )
                }
            }
        }

        // Note that 'changeHours' and 'changeHourText' will be more or less in sync - one is in numbers, the other in 'HH:MM' format.
        // But both can be sorted without problem; they'll stay in sync
        changeHourText.sort()
        changeHours.sort()
        extrachangeHourText.sort()
        extrachangeHours.sort()

        changeHourText.push(...extrachangeHourText)
        changeHours.push(...extrachangeHours)

        return [changeHours, changeHourText]
    }

    public static CreateOhObjectStore(
        tags: Store<Record<string, string>>,
        key: string = "opening_hours",
        prefixToIgnore?: string,
        postfixToIgnore?: string
    ): Store<opening_hours | undefined | "error"> {
        prefixToIgnore ??= ""
        postfixToIgnore ??= ""
        const country = tags.map((tags) => tags._country)
        return tags
            .mapD((tags) => {
                const value: string = tags[key]
                if (value === undefined) {
                    return undefined
                }

                if (
                    (prefixToIgnore || postfixToIgnore) &&
                    value.startsWith(prefixToIgnore) &&
                    value.endsWith(postfixToIgnore)
                ) {
                    return value
                        .substring(prefixToIgnore.length, value.length - postfixToIgnore.length)
                        .trim()
                }
                return value
            })
            .mapD(
                (ohtext) => {
                    try {
                        return OH.CreateOhObject(<any>tags.data, ohtext, country.data)
                    } catch (e) {
                        return "error"
                    }
                },
                [country]
            )
    }

    public static CreateOhObject(
        tags: Record<string, string> & { _lat: number; _lon: number; _country?: string },
        textToParse: string,
        country?: string
    ) {
        // noinspection JSPotentiallyInvalidConstructorUsage
        return new opening_hours(
            textToParse,
            {
                lat: tags._lat,
                lon: tags._lon,
                address: {
                    country_code: country.toLowerCase(),
                    state: undefined,
                },
            },
            <any>{ tag_key: "opening_hours" }
        )
    }

    /**
     * let ranges = <any> [
     *   [
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-11T09:00:00.000Z"),
     *       "endDate": new Date("2023-12-11T12:30:00.000Z")
     *     },
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-11T13:30:00.000Z"),
     *       "endDate": new Date("2023-12-11T18:00:00.000Z")
     *     }
     *   ],
     *   [
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-12T09:00:00.000Z"),
     *       "endDate": new Date("2023-12-12T12:30:00.000Z")
     *     },
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-12T13:30:00.000Z"),
     *       "endDate": new Date("2023-12-12T18:00:00.000Z")
     *     }
     *   ],
     *   [
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-13T09:00:00.000Z"),
     *       "endDate": new Date("2023-12-13T12:30:00.000Z")
     *     },
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-13T13:30:00.000Z"),
     *       "endDate": new Date("2023-12-13T18:00:00.000Z")
     *     }
     *   ],
     *   [
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-14T09:00:00.000Z"),
     *       "endDate": new Date("2023-12-14T12:30:00.000Z")
     *     },
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-14T13:30:00.000Z"),
     *       "endDate": new Date("2023-12-14T18:00:00.000Z")
     *     }
     *   ],
     *   [
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-15T09:00:00.000Z"),
     *       "endDate": new Date("2023-12-15T12:30:00.000Z")
     *     },
     *     {
     *       "isSpecial": false,
     *       "isOpen": true,
     *       "startDate": new Date("2023-12-15T13:30:00.000Z"),
     *       "endDate": new Date("2023-12-15T18:00:00.000Z")
     *     }
     *   ],
     *   [],
     *   []
     * ]
     * OH.weekdaysIdentical(ranges, 0, 1) // => true
     * OH.weekdaysIdentical(ranges, 0, 4) // => true
     * OH.weekdaysIdentical(ranges, 4, 5) // => false
     *
     */

    /**
     * Constructs the opening-ranges for either this week, or for next week if there are no more openings this week.
     * Note: 'today' is mostly used for testing
     *
     * const oh = new opening_hours("mar 15 - oct 15")
     * const ranges = OH.createRangesForApplicableWeek(oh, new Date(2025,4,20,10,0,0))
     * ranges // => {ranges: [[],[],[],[],[],[],[]], startingMonday: new Date(2025,4,18,24,0,0)}
     */
    public static createRangesForApplicableWeek(
        oh: opening_hours,
        today?: Date
    ): {
        ranges: OpeningRange[][]
        startingMonday: Date
    } {
        today ??= new Date()
        today.setHours(0, 0, 0, 0)
        const lastMonday = OH.getMondayBefore(today)
        const nextSunday = new Date(lastMonday)
        nextSunday.setDate(nextSunday.getDate() + 7)

        if (!oh.getState() && !oh.getUnknown()) {
            // POI is currently closed
            const nextChange: Date = oh.getNextChange()
            if (
                // Shop isn't gonna open anymore in this timerange
                nextSunday < nextChange &&
                // And we are already in the weekend to show next week
                (today.getDay() == 0 || today.getDay() == 6)
            ) {
                // We move the range to next week!
                lastMonday.setDate(lastMonday.getDate() + 7)
                nextSunday.setDate(nextSunday.getDate() + 7)
            }
        }

        /* We calculate the ranges when it is opened! */
        return { startingMonday: lastMonday, ranges: OH.GetRanges(oh, lastMonday, nextSunday) }
    }

    public static weekdaysIdentical(openingRanges: OpeningRange[][], startday = 0, endday = 4) {
        const monday = openingRanges[startday]
        for (let i = startday + 1; i <= endday; i++) {
            const weekday = openingRanges[i]
            if (weekday.length !== monday.length) {
                return false
            }
            for (let j = 0; j < weekday.length; j++) {
                const openingRange = weekday[j]
                const mondayRange = monday[j]
                if (
                    openingRange.isOpen !== mondayRange.isOpen &&
                    openingRange.isSpecial !== mondayRange.isSpecial &&
                    openingRange.comment !== mondayRange.comment
                ) {
                    return false
                }
                if (
                    openingRange.startDate.toTimeString() !== mondayRange.startDate.toTimeString()
                ) {
                    return false
                }

                if (openingRange.endDate.toTimeString() !== mondayRange.endDate.toTimeString()) {
                    return false
                }
            }
        }
        return true
    }

    /**
     * Calculates when the business is opened (or on holiday) between two dates.
     * Returns a matrix of ranges, where [0] is a list of ranges when it is opened on monday, [1] is a list of ranges for tuesday, ...
     */
    public static GetRanges(oh: opening_hours, from: Date, to: Date): OpeningRange[][] {
        const values = [[], [], [], [], [], [], []]

        const start = new Date(from)
        // We go one day more into the past, in order to force rendering of holidays in the start of the period
        start.setDate(from.getDate() - 1)

        const iterator = oh.getIterator(start)

        let prevValue = undefined
        while (iterator.advance(to)) {
            if (prevValue) {
                prevValue.endDate = iterator.getDate() as Date
            }
            const endDate = new Date(iterator.getDate()) as Date
            endDate.setHours(0, 0, 0, 0)
            endDate.setDate(endDate.getDate() + 1)
            const value = {
                isSpecial: iterator.getUnknown(),
                isOpen: iterator.getState(),
                comment: iterator.getComment(),
                startDate: iterator.getDate() as Date,
                endDate: endDate, // Should be overwritten by the next iteration
            }
            prevValue = value

            if (value.comment === undefined && !value.isOpen && !value.isSpecial) {
                // simply closed, nothing special here
                continue
            }

            if (value.startDate < from) {
                continue
            }
            // Get day: sunday is 0, monday is 1. We move everything so that monday == 0
            values[(value.startDate.getDay() + 6) % 7].push(value)
        }
        return values
    }

    public static getMondayBefore(d) {
        d = new Date(d)
        const day = d.getDay()
        const diff = d.getDate() - day + (day == 0 ? -6 : 1) // adjust when day is sunday
        return new Date(d.setDate(diff))
    }

    /**
     * OH.parseHHMM("12:30") // => {hours: 12, minutes: 30}
     */
    private static parseHHMM(hhmm: string): { hours: number; minutes: number } {
        if (hhmm === undefined || hhmm == null) {
            return null
        }
        const spl = hhmm.trim().split(":")
        if (spl.length != 2) {
            return null
        }
        const hm = { hours: Number(spl[0].trim()), minutes: Number(spl[1].trim()) }
        if (isNaN(hm.hours) || isNaN(hm.minutes)) {
            return null
        }
        return hm
    }

    /**
     * OH.ParseHhmmRanges("20:00-22:15") // => [{startHour: 20, startMinutes: 0, endHour: 22, endMinutes: 15}]
     * OH.ParseHhmmRanges("20:00-02:15") // => [{startHour: 20, startMinutes: 0, endHour: 2, endMinutes: 15}]
     * OH.ParseHhmmRanges("00:00-24:00") // => [{startHour: 0, startMinutes: 0, endHour: 24, endMinutes: 0}]
     */
    private static ParseHhmmRanges(hhmms: string): {
        startHour: number
        startMinutes: number
        endHour: number
        endMinutes: number
    }[] {
        if (hhmms === "off") {
            return []
        }
        return hhmms
            .split(",")
            .map((s) => s.trim())
            .filter((str) => str !== "")
            .map(OH.parseHHMMRange)
            .filter((v) => v != null)
    }

    private static ParseWeekday(weekday: string): number {
        return OH.daysIndexed[weekday.trim().toLowerCase()]
    }

    private static ParseWeekdayRange(weekdays: string): number[] {
        const split = weekdays.split("-")
        if (split.length == 1) {
            const parsed = OH.ParseWeekday(weekdays)
            if (parsed == null) {
                return null
            }
            return [parsed]
        } else if (split.length == 2) {
            const start = OH.ParseWeekday(split[0])
            const end = OH.ParseWeekday(split[1])
            if ((start ?? null) === null || (end ?? null) === null) {
                return null
            }
            const range = []
            for (let i = start; i <= end; i++) {
                range.push(i)
            }
            return range
        } else {
            return null
        }
    }

    private static ParseWeekdayRanges(weekdays: string): number[] {
        const ranges = []
        const split = weekdays.split(",")
        for (const weekday of split) {
            const parsed = OH.ParseWeekdayRange(weekday)
            if (parsed === undefined || parsed === null) {
                return null
            }
            ranges.push(...parsed)
        }
        return ranges
    }

    /**
     *
     * const oh = {weekday: 0, startHour: 18, startMinutes: 0, endHour: 20, endMinutes: 30}
     * OH.isSame(oh, oh) // => true
     * const ohEndMidnights = {...oh, endMinutes: 0, endHour: 24}
     * OH.isSame(oh, ohEndMidnights) // => false
     * OH.isSame(ohEndMidnights, ohEndMidnights) // => true
     * const ohEndMidnightsAlt = {...ohEndMidnights, endMinutes: 0, endHour: 0}
     * OH.isSame(ohEndMidnightsAlt, ohEndMidnights) // => true
     *
     */
    public static isSame(a: OpeningHour, b: OpeningHour) {
        return (
            a.weekday === b.weekday &&
            a.startHour === b.startHour &&
            a.startMinutes === b.startMinutes &&
            a.endHour % 24 === b.endHour % 24 &&
            a.endMinutes === b.endMinutes
        )
    }
    private static multiply(
        weekdays: number[],
        timeranges: {
            startHour: number
            startMinutes: number
            endHour: number
            endMinutes: number
        }[]
    ): {
        weekday: number
        startHour: number
        startMinutes: number
        endHour: number
        endMinutes: number
    }[] {
        if ((weekdays ?? null) == null || (timeranges ?? null) == null) {
            return null
        }
        const ohs: OpeningHour[] = []
        for (const timerange of timeranges) {
            const overMidnight =
                !(timerange.endHour === 0 && timerange.endMinutes === 0) &&
                (timerange.endHour < timerange.startHour ||
                    (timerange.endHour == timerange.startHour &&
                        timerange.endMinutes < timerange.startMinutes))
            for (const weekday of weekdays) {
                if (!overMidnight) {
                    ohs.push({
                        weekday: weekday,
                        startHour: timerange.startHour,
                        startMinutes: timerange.startMinutes,
                        endHour: timerange.endHour,
                        endMinutes: timerange.endMinutes,
                    })
                } else {
                    ohs.push({
                        weekday: weekday,
                        startHour: timerange.startHour,
                        startMinutes: timerange.startMinutes,
                        endHour: 0,
                        endMinutes: 0,
                    })
                    ohs.push({
                        weekday: (weekday + 1) % 7,
                        startHour: 0,
                        startMinutes: 0,
                        endHour: timerange.endHour,
                        endMinutes: timerange.endMinutes,
                    })
                }
            }
        }
        return ohs
    }

    /**
     * Small utility function for the OH-table. if endHour is '0', rewrite this as '24'
     *
     * const oh = {
     *           weekday: 0,
     *             endMinutes: 0,
     *             endHour: 0,
     *             startHour: 10,
     *             startMinutes: 0
     *         }
     * OH.rangeAs24Hr(oh).endHour // => 24
     */
    static rangeAs24Hr(oh: OpeningHour) {
        if (oh.endHour === 0) {
            return {
                ...oh,
                endHour: 24,
            }
        }
        return oh
    }

    /**
     *
     * @param changeHours number of seconds 'till the start of the day, assuming sorted
     * @param changeHourText
     * @param maxDiff minimum required seconds between two items to be in the same group
     *
     * OH.partitionOHForDistance([0, 15, 5415], ["start", "15s", "1h15s"]) // => [{changeHours: [0, 5415], changeTexts: ["start", "1h15s"]}, {changeHours: [15], changeTexts: ["15s"]}]
     *
     */
    public static partitionOHForDistance(
        changeHours: number[],
        changeHourText: string[],
        maxDiff = 5400
    ): {
        changeHours: number[]
        changeTexts: string[]
    }[] {
        const partitionedHours: { changeHours: number[]; changeTexts: string[] }[] = [
            { changeHours: [changeHours[0]], changeTexts: [changeHourText[0]] },
        ]
        for (let i = 1 /*skip the first one, inited ^*/; i < changeHours.length; i++) {
            const moment = changeHours[i]
            const text = changeHourText[i]
            let depth = 0
            while (depth < partitionedHours.length) {
                const candidate = partitionedHours[depth]
                const lastMoment = candidate.changeHours.at(-1)
                const diff = moment - lastMoment
                if (diff >= maxDiff) {
                    candidate.changeHours.push(moment)
                    candidate.changeTexts.push(text)
                    break
                }
                depth++
            }
            if (depth == partitionedHours.length) {
                // No candidate found - make a new list
                partitionedHours.push({
                    changeTexts: [text],
                    changeHours: [moment],
                })
            }
        }

        return partitionedHours
    }
}

export class ToTextualDescription {
    /**
     * const oh = new opening_hours("mon 12:00-16:00")
     * const ranges = OH.createRangesForApplicableWeek(oh)
     * const tr = ToTextualDescription.createTextualDescriptionFor(oh, ranges.ranges)
     * tr.textFor("en") // => "On Monday from 12:00 till 16:00"
     * tr.textFor("nl") // => "Op maandag van 12:00 tot 16:00"
     *
     * const oh = new opening_hours("mon 12:00-16:00; tu 13:00-14:00")
     * const ranges = OH.createRangesForApplicableWeek(oh)
     * const tr = ToTextualDescription.createTextualDescriptionFor(oh, ranges.ranges)
     * tr.textFor("en") // => "On Monday from 12:00 till 16:00. On Tuesday from 13:00 till 14:00"
     * tr.textFor("nl") // => "Op maandag van 12:00 tot 16:00. Op dinsdag van 13:00 tot 14:00"
     */
    public static createTextualDescriptionFor(
        oh: opening_hours,
        ranges: OpeningRange[][]
    ): Translation | undefined {
        const t = Translations.t.general.opening_hours
        if (!ranges) {
            return undefined
        }
        if (!ranges?.some((r) => r.length > 0)) {
            // <!-- No changes to the opening hours in the next week; probably open 24/7, permanently closed, opening far in the future or unkown -->
            if (oh.getNextChange() === undefined) {
                // <!-- Permenantly in the same state -->
                if (oh.getComment() !== undefined) {
                    return new Translation({ "*": oh.getComment() })
                }

                if (oh.getUnknown()) {
                    return t.unknown
                }
                if (oh.getState()) {
                    return t.open_24_7
                } else {
                    return t.closed_permanently
                }
            }
        }

        // Opened at a more-or-less normal, weekly rhythm
        if (OH.weekdaysIdentical(ranges, 0, 6)) {
            return t.all_days_from.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[0]) })
        }

        const result: Translation[] = []
        const weekdays = [
            "monday",
            "tuesday",
            "wednesday",
            "thursday",
            "friday",
            "saturday",
            "sunday",
        ]

        function addRange(start: number, end: number) {
            for (let i = start; i <= end; i++) {
                const day = weekdays[i]
                if (ranges[i]?.length > 0) {
                    result.push(
                        t[day].Subs({ ranges: ToTextualDescription.createRangesFor(ranges[i]) })
                    )
                }
            }
        }

        if (OH.weekdaysIdentical(ranges, 0, 4)) {
            if (ranges[0].length > 0) {
                result.push(
                    t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[0]) })
                )
            }
        } else {
            addRange(0, 4)
        }

        if (OH.weekdaysIdentical(ranges, 5, 6)) {
            if (ranges[5].length > 0) {
                result.push(
                    t.on_weekdays.Subs({ ranges: ToTextualDescription.createRangesFor(ranges[5]) })
                )
            }
        } else {
            addRange(5, 6)
        }
        return ToTextualDescription.chain(result)
    }

    private static chain(trs: Translation[]): Translation {
        const languages: Record<string, string> = {}
        for (const tr1 of trs) {
            for (const supportedLanguage of tr1.SupportedLanguages()) {
                languages[supportedLanguage] = "{a}. {b}"
            }
        }
        const chainer = new TypedTranslation<{ a; b }>(languages)
        let tr = trs[0]
        for (let i = 1; i < trs.length; i++) {
            tr = chainer.PartialSubsTr("a", tr).PartialSubsTr("b", trs[i])
        }
        return tr
    }

    private static timeString(date: Date) {
        return OH.hhmm(date.getHours(), date.getMinutes())
    }

    private static createRangeFor(range: OpeningRange): Translation {
        return Translations.t.general.opening_hours.ranges.Subs({
            starttime: ToTextualDescription.timeString(range.startDate),
            endtime: ToTextualDescription.timeString(range.endDate),
        })
    }

    private static createRangesFor(ranges: OpeningRange[]): Translation | undefined {
        if (ranges.length === 0) {
            return undefined
        }
        let tr = ToTextualDescription.createRangeFor(ranges[0])
        for (let i = 1; i < ranges.length; i++) {
            tr = Translations.t.general.opening_hours.rangescombined.Subs({
                range0: tr,
                range1: ToTextualDescription.createRangeFor(ranges[i]),
            })
        }
        return tr
    }
}
