//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import Foundation

public class DateUtil {

    private init() {}

    // MARK: - Formatters

    public static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.timeStyle = .none
        formatter.dateStyle = .short
        return formatter
    }()

    public static let timeFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.timeStyle = .short
        formatter.dateStyle = .none
        return formatter
    }()

    public static let weekdayFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("EEEE")
        return formatter
    }()

    public static let monthAndDayFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("M/d")
        return formatter
    }()

    private static let shortDayOfWeekFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("E")
        return formatter
    }()

    private static let otherYearMessageFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("MMM d, yyyy")
        return formatter
    }()

    private static let thisYearMessageFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("MMM d")
        return formatter
    }()

    private static let thisWeekMessageFormatterShort: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("E")
        return formatter
    }()

    private static let thisWeekMessageFormatterLong: DateFormatter = {
        let formatter = DateFormatter()
        formatter.setLocalizedDateFormatFromTemplate("EEEE")
        return formatter
    }()

    // MARK: Day Comparison

    // Returns the difference in days, ignoring hours, minutes, seconds.
    // If both dates are the same date, returns 0.
    // If firstDate is a day before secondDate, returns 1.
    //
    // Note: Assumes both dates use the "current" calendar.
    public static func daysFrom(firstDate: Date, toSecondDate secondDate: Date) -> Int {
        let calendar = Calendar.current
        guard let days = calendar.dateComponents([.day],
                                                 from: calendar.startOfDay(for: firstDate),
                                                 to: calendar.startOfDay(for: secondDate)).day else {
            owsFailDebug("Invalid result.")
            return 0
        }
        return days
    }

    public static func dateIsOlderThanToday(_ date: Date, now: Date? = nil) -> Bool {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: now ?? Date())
        return dayDifference > 0
    }

    public static func dateIsOlderThanYesterday(_ date: Date, now: Date? = nil) -> Bool {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: now ?? Date())
        return dayDifference > 1
    }

    public static func dateIsOlderThanOneWeek(_ date: Date, now: Date? = nil) -> Bool {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: now ?? Date())
        return dayDifference > 6
    }

    public static func dateIsToday(_ date: Date, now: Date? = nil) -> Bool {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: now ?? Date())
        return dayDifference == 0
    }

    public static func dateIsYesterday(_ date: Date, now: Date? = nil) -> Bool {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: now ?? Date())
        return dayDifference == 1
    }

    public static func dateIsThisYear(_ date: Date, now: Date? = nil) -> Bool {
        let calendar = Calendar.current
        return calendar.component(.year, from: date) == calendar.component(.year, from: now ?? Date())
    }

    // MARK: Generic Date Formats

    public static func formatPastTimestampRelativeToNow(_ pastTimestamp: UInt64) -> String {
        let nowTimestamp = NSDate.ows_millisecondTimeStamp()
        let isFutureTimestamp = pastTimestamp >= nowTimestamp

        let pastDate = NSDate.ows_date(withMillisecondsSince1970: pastTimestamp)
        let dateString: String = {
            if isFutureTimestamp || dateIsToday(pastDate) {
                return OWSLocalizedString("DATE_TODAY", comment: "The current day.")
            } else if dateIsYesterday(pastDate) {
                return OWSLocalizedString("DATE_YESTERDAY", comment: "The day before today.")
            } else {
                return dateFormatter.string(from: pastDate)
            }
        }()
        return dateString.appending(" ").appending(timeFormatter.string(from: pastDate))
    }

    public static func formatTimestampShort(_ timestamp: UInt64) -> String {
        return formatDateShort(NSDate.ows_date(withMillisecondsSince1970: timestamp))
    }

    public static func formatDateShort(_ date: Date, nowDate: Date = Date()) -> String {
        let dayDifference = daysFrom(firstDate: date, toSecondDate: nowDate)
        let dateIsOlderThanToday = dayDifference > 0
        let dateIsOlderThanOneWeek = dayDifference > 6

        let dateTimeString: String = {
            if !dateIsThisYear(date) {
                return dateFormatter.string(from: date)
            } else if dateIsOlderThanOneWeek {
                return monthAndDayFormatter.string(from: date)
            } else if dateIsOlderThanToday {
                return shortDayOfWeekFormatter.string(from: date)
            } else {
                return formatMessageTimestampForCVC(date.ows_millisecondsSince1970, shouldUseLongFormat: false)
            }
        }()

        return dateTimeString
    }

    public static func formatTimestampAsTime(_ timestamp: UInt64) -> String {
        return formatDateAsTime(NSDate.ows_date(withMillisecondsSince1970: timestamp))
    }

    public static func formatDateAsTime(_ date: Date) -> String {
        return timeFormatter.string(from: date)
    }

    // MARK: Formatting for UI

    // We might receive a message "from the future" due to a bug or
    // malicious sender or a sender whose device time is misconfigured,
    // etc. Clamp message and date headers dates to the past & present.
    private static func clampBeforeNow(_ date: Date) -> Date {
        let nowDate = Date()
        return date < nowDate ? date : nowDate
    }

    public static func formatMessageTimestampForCVC(_ timestamp: UInt64,
                                                    shouldUseLongFormat: Bool) -> String {
        let date = clampBeforeNow(Date(millisecondsSince1970: timestamp))
        let calendar = Calendar.current
        let minutesDiff = calendar.dateComponents([.minute], from: date, to: Date()).minute ?? 0
        if minutesDiff < 1 {
            return OWSLocalizedString("DATE_NOW",
                                     comment: "The present; the current time.")
        } else if minutesDiff <= 60 {
            let shortFormat = OWSLocalizedString("DATE_MINUTES_AGO_%d", tableName: "PluralAware",
                                                comment: "Format string for a relative time, expressed as a certain number of minutes in the past. Embeds {{The number of minutes}}.")
            let longFormat = OWSLocalizedString("DATE_MINUTES_AGO_LONG_%d", tableName: "PluralAware",
                                               comment: "Full format string for a relative time, expressed as a certain number of minutes in the past. Embeds {{The number of minutes}}.")
            let format = shouldUseLongFormat ? longFormat : shortFormat
            return String.localizedStringWithFormat(format, minutesDiff)
        } else {
            return timeFormatter.string(from: date)
        }
    }

    public static func formatDateHeaderForCVC(_ date: Date) -> String {
        let date = clampBeforeNow(date)
        let calendar = Calendar.current
        let monthsDiff = calendar.dateComponents([.month], from: date, to: Date()).month ?? 0
        if monthsDiff >= 6 {
            // Mar 8, 2017
            return dateHeaderOldDateFormatter.string(from: date)
        } else if dateIsOlderThanYesterday(date) {
            // Wed, Mar 3
            return dateHeaderRecentDateFormatter.string(from: date)
        } else {
            // Today / Yesterday
            return dateHeaderRelativeDateFormatter.string(from: date)
        }
    }

    /// Format an old date with the year. For example, "Mar 8, 2017"
    public static func formatOldDate(_ date: Date) -> String {
        dateHeaderOldDateFormatter.string(from: date)
    }

    public static func formatTimestampRelatively(_ timestamp: UInt64) -> String {
        let date = clampBeforeNow(Date(millisecondsSince1970: timestamp))
        let calendar = Calendar.current
        let minutesDiff = calendar.dateComponents([.minute], from: date, to: Date()).minute ?? 0
        if minutesDiff < 1 {
            return OWSLocalizedString("DATE_NOW", comment: "The present; the current time.")
        } else {
            let secondsDiff = calendar.dateComponents([.second], from: date, to: Date()).second ?? 0
            return formatDuration(seconds: UInt32(secondsDiff), useShortFormat: true)
        }
    }

    public static func formatDuration(seconds: UInt32, useShortFormat: Bool) -> String {
        let secondsPerMinute: UInt32 = 60
        let secondsPerHour = secondsPerMinute * 60
        let secondsPerDay = secondsPerHour * 24
        let secondsPerWeek = secondsPerDay * 7

        let formatString: String
        let formatReplacement: UInt32

        if seconds < secondsPerMinute { // XX Seconds
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SECONDS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of seconds}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5s' not '5 s'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SECONDS",
                    comment: "{{number of seconds}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 seconds}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds

        } else if seconds < secondsPerMinute * 3 / 2 { // 1 Minute
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_MINUTES_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of minutes}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5m' not '5 m'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SINGLE_MINUTE",
                    comment: "{{1 minute}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 minute}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerMinute

        } else if seconds < secondsPerHour { // Multiple Minutes
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_MINUTES_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of minutes}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5m' not '5 m'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_MINUTES",
                    comment: "{{number of minutes}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 minutes}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerMinute

        } else if seconds < secondsPerHour * 3 / 2 { // 1 Hour
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_HOURS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of hours}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5h' not '5 h'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SINGLE_HOUR",
                    comment: "{{1 hour}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 hour}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerHour

        } else if seconds < secondsPerDay { // Multiple Hours
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_HOURS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of hours}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5h' not '5 h'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_HOURS",
                    comment: "{{number of hours}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 hours}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerHour

        } else if seconds < secondsPerDay * 3 / 2 { // 1 Day
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_DAYS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of days}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5d' not '5 d'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SINGLE_DAY",
                    comment: "{{1 day}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 day}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerDay

        } else if seconds < secondsPerWeek { // Multiple Days
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_DAYS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of days}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5d' not '5 d'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_DAYS",
                    comment: "{{number of days}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 days}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerDay

        } else if seconds < secondsPerWeek * 3 / 2 { // 1 Week
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_WEEKS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of weeks}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5w' not '5 w'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_SINGLE_WEEK",
                    comment: "{{1 week}} embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{1 week}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerWeek

        } else { // Multiple weeks
            if useShortFormat {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_WEEKS_SHORT_FORMAT",
                    comment: "Label text below navbar button, embeds {{number of weeks}}. Must be very short, like 1 or 2 characters, The space is intentionally omitted between the text and the embedded duration so that we get, e.g. '5w' not '5 w'. See other *_TIME_AMOUNT strings"
                )
            } else {
                formatString = OWSLocalizedString(
                    "TIME_AMOUNT_WEEKS",
                    comment: "{{number of weeks}}, embedded in strings, e.g. 'Alice updated disappearing messages expiration to {{5 weeks}}'. See other *_TIME_AMOUNT strings"
                )
            }
            formatReplacement = seconds / secondsPerWeek
        }

        return String(
            format: formatString,
            NumberFormatter.localizedString(from: NSNumber(value: formatReplacement), number: .none)
        )
    }

    private static let dateHeaderRecentDateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        // Tue, Jun 6
        formatter.setLocalizedDateFormatFromTemplate("EE, MMM d")
        return formatter
    }()

    private static let dateHeaderOldDateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.timeStyle = .none
        // Mar 8, 2017
        formatter.dateStyle = .medium
        return formatter
    }()

    private static let dateHeaderRelativeDateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.timeStyle = .none
        formatter.dateStyle = .short
        // Today / Yesterday
        formatter.doesRelativeDateFormatting = true
        return formatter
    }()

    public static func isSameDay(timestamp timestamp1: UInt64, timestamp timestamp2: UInt64) -> Bool {
        isSameDay(date: NSDate.ows_date(withMillisecondsSince1970: timestamp1),
                  date: NSDate.ows_date(withMillisecondsSince1970: timestamp2))
    }

    public static func isSameDay(date date1: Date, date date2: Date) -> Bool {
        0 == daysFrom(firstDate: date1, toSecondDate: date2)
    }

    public static func format(interval: TimeInterval) -> String {
        String(format: "%0.3f", interval)
    }

    // MARK: Dynamic timestamp refresh

    /// Formats the given date, whose display is expected to dynamically update
    /// as time goes on.
    ///
    /// For example, this method may be called when the given date is 3s away,
    /// at which point the formatted result will be "Now"; however, in 57s the
    /// display of this date should switch to "1m".
    ///
    /// - Note
    /// Even if this method returns a `nil` `nextRefreshTimestamp`, callers may
    /// want to trigger additional refreshes; for example, when a view appears,
    /// or in response to a ``UIApplication/significantTimeChangeNotification``.
    ///
    /// - Returns
    /// The formatted date, and a time at which the caller should re-invoke this
    /// method to get an updated formatting for the given date.
    ///
    /// A `nil` `nextRefreshTimestamp` indicates that the next refresh time is
    /// sufficiently far in the future as to not merit scheduling a refresh. At
    /// the time of writing, the threshold for not returning a refresh time is
    /// 1h.
    public static func formatDynamicDateShort(_ date: Date) -> (
        formatted: String,
        nextRefreshTime: Date?
    ) {
        let nowDate = Date()

        let nextRefreshDate: Date? = {
            let calendar = Calendar.current
            let dateDiff = calendar.dateComponents([.minute, .second], from: date, to: nowDate)

            if dateDiff.minute! > 60 {
                /// If we're more than an hour away, don't bother scheduling a
                /// refresh. The display string won't change for a long time.
                return nil
            } else {
                /// If we're less than an hour away the display string is going
                /// to change on a minute-by-minute basis, so we'll return a
                /// refresh time for when we'll cross the next minute boundary.
                let secondsDiff = dateDiff.second!
                return nowDate.addingTimeInterval(Double(60 - secondsDiff))
            }
        }()

        return (
            formatDateShort(date, nowDate: nowDate),
            nextRefreshDate
        )
    }
}
