#!/usr/bin/env swift

import EventKit
import Foundation


let eventStore = EKEventStore()


private func requestAccess() -> Bool {
    let semaphore = DispatchSemaphore(value: 0)
    var grantedAccess = false
    eventStore.requestAccess(to: .reminder) { granted, _ in
        grantedAccess = granted
        semaphore.signal()
    }
    semaphore.wait()
    return grantedAccess
}


struct CodableSummary : Codable {
    let currentTime: Date
    let source: String
    let reminders: [CodableReminder]
}


struct CodableReminder : Codable {
    let uid: String
    let title: String?
    let notes: String?
    let completed: Bool
    let priority: Int
    let completionDate: Date?
    let creationDate: Date?
    let dueDate: Date?
    let url: URL?
}


private func toCodable(_ ekReminder: EKReminder) -> CodableReminder {
    let dueDate: Date?
    if let dueDateComponents = ekReminder.dueDateComponents {
        if let actualDueDate = dueDateComponents.date {
            dueDate = actualDueDate
        } else {
            let calendar = dueDateComponents.calendar ?? Calendar.current
            dueDate = calendar.date(from: dueDateComponents)
        }
    } else {
        dueDate = nil
    }
    return CodableReminder(
        uid: ekReminder.calendarItemIdentifier,
        title: ekReminder.title,
        notes: ekReminder.notes,
        completed: ekReminder.isCompleted,
        priority: ekReminder.priority,
        completionDate: ekReminder.completionDate,
        creationDate: ekReminder.creationDate,
        dueDate: dueDate,
        url: ekReminder.url?.absoluteURL
    )
}


private func intToPriority(_ value: Int) -> String {
    if let priority = EKReminderPriority(rawValue: UInt(value)) {
        switch priority {
        case EKReminderPriority.high:
            return "high"
        case EKReminderPriority.medium:
            return "medium"
        case EKReminderPriority.low:
            return "low"
        case EKReminderPriority.none:
            return "none"
        @unknown default:
            return "unknown"
        }
    }
    return "unknown"
}


private func componentsToIsoDate(_ value: DateComponents) -> String {
    if let year = value.year {
        if let month = value.month {
            if let day = value.day {
                return String(format: "%04d-%02d-%02-d", year, month, day)
            }
        }
    }
    return "0000-00-00"
}


private func nicerDebugEkReminder(_ ekReminder: EKReminder) -> String {
    if let title = ekReminder.title {
        if title.count > 0 {
            return String(format: "\"%@\" [%@]", title.replacingOccurrences(of: "\n", with: " "), ekReminder.calendarItemIdentifier)
        }
    }
    if ekReminder.description.count > 0 {
        return String(format: "\"%@\" [%@]", ekReminder.description.replacingOccurrences(of: "\n", with: " "), ekReminder.calendarItemIdentifier)
    }
    return String(format: "\"\" [%@]", ekReminder.calendarItemIdentifier)
}


private func dateCloseEnough(_ one: Date?, _ two: Date?) -> Bool {
    if let one_nn = one {
        if let two_nn = two {
            if abs(one_nn.distance(to: two_nn)) >= 1.0 {
                return false;
            }
        } else {
            return false;
        }
    } else if two != nil {
        return false;
    }
    return true;
}


private func updateEkReminder(ekReminder: EKReminder, reference: CodableReminder, timeZone: TimeZone) {
    if (ekReminder.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) != (reference.title ?? "").trimmingCharacters(in: .whitespacesAndNewlines) {
        print("Updating title on \(nicerDebugEkReminder(ekReminder))")
        print("  old: [\(ekReminder.title ?? "<nil>")]")
        print("  new: [\(reference.title ?? "<nil>")]")
        ekReminder.title = reference.title
    }
    if (ekReminder.notes ?? "").trimmingCharacters(in: .whitespacesAndNewlines) != (reference.notes ?? "").trimmingCharacters(in: .whitespacesAndNewlines) {
        print("Updating notes on \(nicerDebugEkReminder(ekReminder))")
        print("  old: [\(ekReminder.notes ?? "<nil>")]")
        print("  new: [\(reference.notes ?? "<nil>")]")
        ekReminder.notes = reference.notes
    }
    if ekReminder.isCompleted != reference.completed {
        print("Updating isCompleted on \(nicerDebugEkReminder(ekReminder))")
        print("  old: \(ekReminder.isCompleted)")
        print("  new: \(reference.completed)")
        ekReminder.isCompleted = reference.completed
    }
    if ekReminder.priority != reference.priority {
        print("Updating priority on \(nicerDebugEkReminder(ekReminder))")
        print("  old: \(intToPriority(ekReminder.priority)) [\(ekReminder.priority)]")
        print("  new: \(intToPriority(reference.priority)) [\(reference.priority)]")
        ekReminder.priority = reference.priority
    }
    if !dateCloseEnough(ekReminder.completionDate, reference.completionDate) {
        print("Updating completionDate on \(nicerDebugEkReminder(ekReminder))")
        if let date = ekReminder.completionDate {
            print("  old: \(date) [\(date.timeIntervalSince1970)]")
        } else {
            print("  old: <nil>")
        }
        if let date = reference.completionDate {
            print("  new: \(date) [\(date.timeIntervalSince1970)]")
        } else {
            print("  new: <nil>")
        }
        ekReminder.completionDate = reference.completionDate
    }
    if !dateCloseEnough(ekReminder.creationDate, reference.creationDate) {
        print("Different creationDate on \(nicerDebugEkReminder(ekReminder))")
        if let date = ekReminder.completionDate {
            print("  old: \(date) [\(date.timeIntervalSince1970)]")
        } else {
            print("  old: <nil>")
        }
        if let date = reference.completionDate {
            print("  new: \(date) [\(date.timeIntervalSince1970)]")
        } else {
            print("  new: <nil>")
        }
        print("  cannot change creationDate!")
    }
    
    if let dueDate = reference.dueDate {
        let calendar = ekReminder.dueDateComponents?.calendar ?? Calendar(identifier: .gregorian)
        var dueDateComponents = calendar.dateComponents(in: timeZone, from: dueDate)
        if ((dueDateComponents.hour == 0 || dueDateComponents.hour == nil)
                && (dueDateComponents.minute == 0 || dueDateComponents.minute == nil)
                && (dueDateComponents.second == 0 || dueDateComponents.second == nil)
                && (dueDateComponents.nanosecond == 0 || dueDateComponents.nanosecond == nil)) {

            dueDateComponents.hour = nil
            dueDateComponents.minute = nil
            dueDateComponents.second = nil
            dueDateComponents.nanosecond = nil
            dueDateComponents.timeZone = nil
            dueDateComponents.weekday = nil
            dueDateComponents.weekdayOrdinal = nil
            dueDateComponents.quarter = nil
            dueDateComponents.weekOfMonth = nil
            dueDateComponents.weekOfYear = nil
            dueDateComponents.yearForWeekOfYear = nil
            dueDateComponents.isLeapMonth = nil
        }
        if let oldDueDateComponents = ekReminder.dueDateComponents {
            if oldDueDateComponents != dueDateComponents {
                print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))")
                print("  old: \(componentsToIsoDate(oldDueDateComponents)) [\(oldDueDateComponents)]")
                print("  new: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]")
                ekReminder.dueDateComponents = dueDateComponents
            }
        } else {
            print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))")
            print("  old: <nil>")
            print("  new: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]")
            ekReminder.dueDateComponents = dueDateComponents
        }
    } else if let oldDueDateComponents = ekReminder.dueDateComponents {
        print("Updating dueDate on \(nicerDebugEkReminder(ekReminder))")
        print("  old: \(componentsToIsoDate(oldDueDateComponents)) [\(oldDueDateComponents)]")
        print("  new: <nil>")
    }
}


private func newReminder(store: EKEventStore, reference: CodableReminder, timeZone: TimeZone) -> EKReminder {
    let reminder = EKReminder(eventStore: store)
    reminder.title = reference.title
    reminder.notes = reference.notes
    reminder.isCompleted = reference.completed
    reminder.priority = reference.priority
    reminder.completionDate = reference.completionDate
    reminder.url = reference.url
    reminder.calendar = eventStore.defaultCalendarForNewReminders()
    
    print("New reminder \(reminder.calendarItemIdentifier)")
    print("  title: [\(reminder.title ?? "<nil>")]")
    print("  notes: [\(reminder.notes ?? "<nil>")]")
    print("  isCompleted: \(reminder.isCompleted)")
    print("  priority: \(reminder.priority)")
    if let date = reminder.completionDate {
        print("  completionDate: \(date) [\(date.timeIntervalSince1970)]")
    } else {
        print("  completionDate: <nil>")
    }
    if let date = reminder.creationDate {
        print("  creationDate: \(date) [\(date.timeIntervalSince1970)]")
    } else {
        print("  creationDate: <nil>")
    }
    
    if let dueDate = reference.dueDate {
        var dueDateComponents = Calendar(identifier: .gregorian).dateComponents(in: timeZone, from: dueDate)
        if ((dueDateComponents.hour == 0 || dueDateComponents.hour == nil)
                && (dueDateComponents.minute == 0 || dueDateComponents.minute == nil)
                && (dueDateComponents.second == 0 || dueDateComponents.second == nil)
                && (dueDateComponents.nanosecond == 0 || dueDateComponents.nanosecond == nil)) {
            dueDateComponents.hour = nil
            dueDateComponents.minute = nil
            dueDateComponents.second = nil
            dueDateComponents.nanosecond = nil
            dueDateComponents.timeZone = nil
            dueDateComponents.weekday = nil
            dueDateComponents.weekdayOrdinal = nil
            dueDateComponents.quarter = nil
            dueDateComponents.weekOfMonth = nil
            dueDateComponents.weekOfYear = nil
            dueDateComponents.yearForWeekOfYear = nil
            dueDateComponents.isLeapMonth = nil
        }
        reminder.dueDateComponents = dueDateComponents
        print("  dueDateComponents: \(componentsToIsoDate(dueDateComponents)) [\(dueDateComponents)]")
    } else {
        print("  dueDateComponents: <nil>")
    }
    
    print("  url: \(reminder.url?.absoluteString ?? "<nil>")")
    
    return reminder
}


private func dump(timeZone: TimeZone) {
    let calendars = eventStore.calendars(for: EKEntityType.reminder)
    let predicate = eventStore.predicateForReminders(in: calendars)
    let semaphore = DispatchSemaphore(value: 0)
    var remindersList: [CodableReminder] = []
    eventStore.fetchReminders(matching: predicate) { (ekReminders) in
        for reminder in ekReminders ?? [EKReminder]() {
            remindersList.append(toCodable(reminder))
        }
        semaphore.signal()
    }
    semaphore.wait()
    
    let currentHost = Host.current().name ?? "unknown-host"
    let currentUser = NSUserName()
    let source = "\(currentUser)@\(currentHost)"
    let encoder = JSONEncoder()
    let customTzDateFormatter = DateFormatter()
    customTzDateFormatter.timeZone = timeZone
    customTzDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
    encoder.dateEncodingStrategy = .formatted(customTzDateFormatter)
    encoder.outputFormatting = .prettyPrinted
    if let json = try? encoder.encode(CodableSummary(currentTime: Date(), source: source, reminders: remindersList)) {
        print(String(data: json, encoding: .utf8)!)
        exit(0)
    } else {
        print("Could not encode: " + remindersList.debugDescription)
        exit(2)
    }
}


private func load(timeZone: TimeZone, commit: Bool) {
    let standardInput = FileHandle.standardInput
    let decoder = JSONDecoder()
    let customTzDateFormatter = DateFormatter()
    customTzDateFormatter.timeZone = timeZone
    customTzDateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
    decoder.dateDecodingStrategy = .formatted(customTzDateFormatter)
    let decoded: CodableSummary
    
    var input: Data = Data()
    var input_: Data
    repeat {
        input_ = standardInput.availableData
        input.append(input_)
    } while (input_.count > 0)
    
    do {
        decoded = try decoder.decode(CodableSummary.self, from: input)
    } catch {
        print("Could not decode: \(error)")
        exit(4)
    }
    
    print("Updating with data from \(decoded.source) at \(decoded.currentTime)")
    var success = true
    for reminder in decoded.reminders {
        if let ekReminder = eventStore.calendarItem(withIdentifier: reminder.uid) {
            updateEkReminder(ekReminder: ekReminder as! EKReminder, reference: reminder, timeZone: timeZone)
            try! eventStore.save(ekReminder as! EKReminder, commit: false)
        } else {
            if reminder.uid == "undefined" {
                let newReminder = newReminder(store: eventStore, reference: reminder, timeZone: timeZone)
                try! eventStore.save(newReminder, commit: false)
            } else {
                print("Could not find matching reminder with identifier \(reminder.uid)")
                success = false
            }
        }
    }
    if success && commit {
        print("Comitting...")
        try! eventStore.commit()
    }
}


if requestAccess() {
    let timeZone: TimeZone
    if let tzIndex = CommandLine.arguments.firstIndex(of: "--timezone") {
        if let userTimeZone = TimeZone.init(identifier: CommandLine.arguments[tzIndex + 1]) {
            timeZone = userTimeZone
        } else {
            print("Invalid time zone \(CommandLine.arguments[tzIndex + 1])")
            exit(5)
        }
    } else {
        timeZone = TimeZone.current
    }
    
    if (CommandLine.arguments.contains("--load")) {
        load(timeZone: timeZone, commit: CommandLine.arguments.contains("--commit"))
    } else if (CommandLine.arguments.contains("--dump")) {
        dump(timeZone: timeZone)
    } else if (isatty(fileno(stdin)) == 0) {
        load(timeZone: timeZone, commit: CommandLine.arguments.contains("--commit"))
    } else {
        dump(timeZone: timeZone)
    }
} else {
    print("You need to grant reminders access")
    exit(1)
}