//
//  TigerCenterParsers.swift
//  TigerDine
//
//  Created by Campbell on 9/19/25.
//

import Foundation

/// Gets the current open status of a location based on the open time and close time.
func parseOpenStatus(openTime: Date, closeTime: Date, referenceTime: Date) -> OpenStatus {
    // If the location is open but the close time is within the next 30 minutes, label it as closing soon, and do the opposite
    // if it's closed but the open time is within the next 30 minutes.
    let calendar = Calendar.current
    if referenceTime >= openTime && referenceTime <= closeTime {
        // This is basically just for Bytes, it checks the case where the open and close times are exactly 24 hours apart, which is
        // only true for 24-hour locations.
        if closeTime == calendar.date(byAdding: .day, value: 1, to: openTime)! {
            return .open
        } else if closeTime < calendar.date(byAdding: .minute, value: 30, to: referenceTime)! {
            return .closingSoon
        }
        return .open
    } else if referenceTime < openTime && openTime <= calendar.date(byAdding: .minute, value: 30, to: referenceTime)! {
        return .openingSoon
    } else {
        return .closed
    }
}

/// Gets the current open status of a location with multiple opening periods based on all of its open and close times.
func parseMultiOpenStatus(diningTimes: [DiningTimes]?, referenceTime: Date) -> OpenStatus {
    var openStatus: OpenStatus = .closed
    
    guard let diningTimes, !diningTimes.isEmpty else {
        return .closed
    }
    
    for i in diningTimes.indices {
        openStatus = parseOpenStatus(
            openTime: diningTimes[i].openTime,
            closeTime: diningTimes[i].closeTime,
            referenceTime: referenceTime
        )
        
        // If we're closing soon for the current opening period, but then we're opening again at the same time we close in the next
        // period, we should just stick with "Open" because showing "Closing Soon" for 30 minutes and then going straight back to
        // "Open" is kinda confusing. This issue has been observed with Petals specifically.
        if openStatus == .closingSoon && i != diningTimes.indices.last {
            if diningTimes[i].closeTime == diningTimes[i + 1].openTime {
                openStatus = .open
            }
        }
        
        // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
        // accurately catch Gracie's/Brick City Cafe's multiple open periods each day.
        if openStatus != .closed {
            return openStatus
        }
    }
    
    return .closed
}

/// Parses the JSON responses from the TigerCenter API into the format used throughout TigerDine.
func parseLocationInfo(location: DiningLocationParser, forDate: Date?) -> DiningLocation {
    print("beginning parse for \(location.name) (id: \(location.id))")
    
    // The descriptions sometimes have HTML <br /> tags despite also having \n. Those need to be removed.
    let desc = location.description.replacingOccurrences(of: "<br />", with: "")
    
    // Check if this location's ID is in the TigerCenter -> FD MealPlanner ID map and save those IDs if it is.
    let fdmpIds: FDMPIds? = if tCtoFDMPMap.keys.contains(location.id) {
        FDMPIds(
            locationId: tCtoFDMPMap[location.id]!.0,
            accountId: tCtoFDMPMap[location.id]!.1
        )
    } else {
        nil
    }
    
    // Generate a maps URL from the mdoId key. This is required because the mapsUrl served by TigerCenter is not compatible with
    // the new RIT map that was deployed in December 2025.
    let mapsUrl = "https://maps.rit.edu/?mdo_id=\(location.mdoId)"
    
    // Early return if there are no events, good for things like the food trucks which can very easily have no openings in a week.
    if location.events.isEmpty {
        return DiningLocation(
            id: location.id,
            mdoId: location.mdoId,
            fdmpIds: fdmpIds,
            name: location.name,
            summary: location.summary,
            desc: desc,
            mapsUrl: mapsUrl,
            date: forDate ?? Date(),
            diningTimes: nil,
            open: .closed,
            visitingChefs: nil,
            dailySpecials: nil
        )
    }
    
    var openStrings: [String] = []
    var closeStrings: [String] = []
    
    // Dining locations have a regular schedule, but then they also have exceptions listed for days like weekends or holidays. If there
    // are exceptions, use those times for the day, otherwise we can just use the default times. Also check for repeats! The response
    // data can include those somtimes, for reasons:tm:
    for event in location.events {
        if let exceptions = event.exceptions,
            !exceptions.isEmpty,
           // This additional check is necessary, because sometimes the exceptions are silly and are doing something like marking
           // a location as closed on a day that isn't included in the regular schedule anyway. That breaks things. This check
           // ensures that the exception being looked at applies for the day we're parsing for before trying to follow it.
            exceptions[0].daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased())
        {
            // Only save the exception times if the location is actually open during those times, and if these times aren't a repeat.
            // I've seen repeats for Brick City Cafe specifically, where both the breakfast and lunch standard open periods had
            // exceptions listing the same singluar brunch period. That feels like a stupid choice but oh well.
            if exceptions[0].open, !openStrings.contains(exceptions[0].startTime), !closeStrings.contains(exceptions[0].endTime) {
                openStrings.append(exceptions[0].startTime)
                closeStrings.append(exceptions[0].endTime)
            }
        } else {
            if !openStrings.contains(event.startTime), !closeStrings.contains(event.endTime) {
                // Verify that the current weekday falls within the schedule. The regular event schedule specifies which days of the
                // week it applies to, and if the current day isn't in that list and there are no exceptions, that means there are no
                // hours for this location.
                if event.daysOfWeek.contains(weekdayFromDate.string(from: forDate ?? Date()).uppercased()) {
                    openStrings.append(event.startTime)
                    closeStrings.append(event.endTime)
                }
            }
        }
    }
    
    // Early return if there are no valid opening times, most likely because the day's exceptions dictate that the location is closed.
    // Mostly comes into play on holidays.
    if openStrings.isEmpty || closeStrings.isEmpty {
        return DiningLocation(
            id: location.id,
            mdoId: location.mdoId,
            fdmpIds: fdmpIds,
            name: location.name,
            summary: location.summary,
            desc: desc,
            mapsUrl: mapsUrl,
            date: forDate ?? Date(),
            diningTimes: nil,
            open: .closed,
            visitingChefs: nil,
            dailySpecials: nil)
    }
    
    // I hate all of this date component nonsense.
    var openDates: [Date] = []
    var closeDates: [Date] = []
    
    let calendar = Calendar.current
    let now = Date()
    
    for i in 0..<openStrings.count {
        let openParts = openStrings[i].split(separator: ":").map { Int($0) ?? 0 }
        let openTimeComponents = DateComponents(hour: openParts[0], minute: openParts[1], second: openParts[2])
        
        let closeParts = closeStrings[i].split(separator: ":").map { Int($0) ?? 0 }
        let closeTimeComponents = DateComponents(hour: closeParts[0], minute: closeParts[1], second: closeParts[2])
        
        openDates.append(calendar.date(
            bySettingHour: openTimeComponents.hour!,
            minute: openTimeComponents.minute!,
            second: openTimeComponents.second!,
            of: now)!
        )
        
        closeDates.append(calendar.date(
            bySettingHour: closeTimeComponents.hour!,
            minute: closeTimeComponents.minute!,
            second: closeTimeComponents.second!,
            of: now)!
        )
    }
    var diningTimes: [DiningTimes] = []
    for i in 0..<openDates.count {
        diningTimes.append(DiningTimes(openTime: openDates[i], closeTime: closeDates[i]))
    }
    
    // If the closing time is less than or equal to the opening time, it's probably midnight and means either open until midnight
    // or open 24/7, in the case of Bytes.
    for i in diningTimes.indices {
        if diningTimes[i].closeTime <= diningTimes[i].openTime {
            diningTimes[i].closeTime = calendar.date(byAdding: .day, value: 1, to: diningTimes[i].closeTime)!
        }
    }
    
    // Sometimes the openings are not in order, for some reason. I'm observing this with Brick City, where for some reason the early
    // opening is event 1, and the later opening is event 0. This is silly so let's reverse it.
    diningTimes.sort { $0.openTime < $1.openTime }
    
    // Get the current open status for a location. Details about how this works can be seen in the docs for parseOpenStatus().
    var openStatus: OpenStatus = .closed
    for i in diningTimes.indices {
        openStatus = parseOpenStatus(openTime: diningTimes[i].openTime, closeTime: diningTimes[i].closeTime, referenceTime: now)
        // If the first event pass came back closed, loop again in case a later event has a different status. This is mostly to
        // accurately catch Gracie's multiple open periods each day.
        if openStatus != .closed {
            break
        }
    }
    
    // Parse the "menus" array and keep track of visiting chefs at this location, if there are any. If not then we can just save nil.
    // The time formats used for visiting chefs are inconsistent and suck so that part of this code might be kind of rough. I can
    // probably make it a little better but I think most of the blame goes to TigerCenter here.
    // Also save the daily specials. This is more of a footnote because that's just taking a string and saving it as two strings.
    let visitingChefs: [VisitingChef]?
    let dailySpecials: [DailySpecial]?
    if !location.menus.isEmpty {
        var chefs: [VisitingChef] = []
        var specials: [DailySpecial] = []
        for menu in location.menus {
            if menu.category == "Visiting Chef" {
                print("found visiting chef: \(menu.name)")
                var name: String = menu.name
                let splitString = name.split(separator: "(", maxSplits: 1)
                name = String(splitString[0]).trimmingCharacters(in: .whitespaces)
                // Time parsing nonsense starts here. Extracts the time from a string like "Chef (4-7p.m.)", splits it at the "-",
                // strips the non-numerical characters from each part, parses it as a number and adds 12 hours as needed, then creates
                // a Date instance for that time on today's date.
                let timeStrings = String(splitString[1]).replacingOccurrences(of: ")", with: "").split(separator: "-", maxSplits: 1)
                print("raw open range: \(timeStrings)")
                let openTime: Date
                let closeTime: Date
                if let openString = timeStrings.first?.trimmingCharacters(in: .whitespaces) {
                    // If the time is NOT in the morning, add 12 hours.
                    let openHour = if openString.contains("a.m") {
                        Int(openString.filter("0123456789".contains))!
                    } else {
                        Int(openString)! + 12
                    }
                    let openTimeComponents = DateComponents(hour: openHour, minute: 0, second: 0)
                    openTime = calendar.date(
                        bySettingHour: openTimeComponents.hour!,
                        minute: openTimeComponents.minute!,
                        second: openTimeComponents.second!,
                        of: forDate ?? now)!
                } else {
                    break
                }
                if let closeString = timeStrings.last?.filter("0123456789".contains) {
                    // I've chosen to assume that no visiting chef will ever close in the morning. This could bad choice but I have
                    // yet to see any evidence of a visiting chef leaving before noon so far.
                    let closeHour = Int(closeString)! + 12
                    let closeTimeComponents = DateComponents(hour: closeHour, minute: 0, second: 0)
                    closeTime = calendar.date(
                        bySettingHour: closeTimeComponents.hour!,
                        minute: closeTimeComponents.minute!,
                        second: closeTimeComponents.second!,
                        of: forDate ?? now)!
                } else {
                    break
                }
                
                // Parse the chef's status, mapping the OpenStatus to a VisitingChefStatus.
                let visitngChefStatus: VisitingChefStatus = switch parseOpenStatus(openTime: openTime, closeTime: closeTime, referenceTime: now) {
                case .open:
                        .hereNow
                case .closed:
                    if now < openTime {
                        .arrivingLater
                    } else {
                        .gone
                    }
                case .openingSoon:
                        .arrivingSoon
                case .closingSoon:
                        .leavingSoon
                }
                
                chefs.append(VisitingChef(
                    name: name,
                    description: menu.description ?? "No description available", // Some don't have descriptions, apparently.
                    openTime: openTime,
                    closeTime: closeTime,
                    status: visitngChefStatus)
                )
            } else if menu.category == "Daily Specials" {
                print("found daily special: \(menu.name)")
                let splitString = menu.name.split(separator: "(", maxSplits: 1)
                specials.append(
                    DailySpecial(
                        name: String(splitString[0]),
                        type: String(splitString.count > 1 ? String(splitString[1]) : "").replacingOccurrences(of: ")", with: "")
                    )
                )
            }
        }
        visitingChefs = chefs
        dailySpecials = specials
    } else {
        visitingChefs = nil
        dailySpecials = nil
    }
    
    return DiningLocation(
        id: location.id,
        mdoId: location.mdoId,
        fdmpIds: fdmpIds,
        name: location.name,
        summary: location.summary,
        desc: desc,
        mapsUrl: mapsUrl,
        date: forDate ?? Date(),
        diningTimes: diningTimes,
        open: openStatus,
        visitingChefs: visitingChefs,
        dailySpecials: dailySpecials
    )
}

extension DiningLocation {
    // Updates the open status of a location and of its visiting chefs, so that the labels in the UI update automatically as
    // time progresses and locations open/close/etc.
    mutating func updateOpenStatus() {
        let now = Date()
        // Gets the open status with the multi opening period compatible function.
        self.open = parseMultiOpenStatus(diningTimes: diningTimes, referenceTime: now)
        if let visitingChefs = visitingChefs, !visitingChefs.isEmpty {
            for i in visitingChefs.indices {
                self.visitingChefs![i].status = switch parseOpenStatus(
                    openTime: visitingChefs[i].openTime,
                    closeTime: visitingChefs[i].closeTime,
                    referenceTime: now) {
                case .open:
                        .hereNow
                case .closed:
                    if now < visitingChefs[i].openTime {
                        .arrivingLater
                    } else {
                        .gone
                    }
                case .openingSoon:
                        .arrivingSoon
                case .closingSoon:
                        .leavingSoon
                }
            }
        }
    }
}
