import CocoaLumberjackSwift

public class RemoteNotificationsAPIController: Fetcher {

    // MARK: Decodable: NotificationsResult

    struct ResultError: Decodable {
        let code, info: String?
    }

    public struct NotificationsResult: Decodable {
        
        struct Query: Decodable {
            
            struct Notifications: Decodable {
                let list: [Notification]
                let continueId: String?
                
                enum CodingKeys: String, CodingKey {
                    case list
                    case continueId = "continue"
                }
            }
            
            let notifications: Notifications?
        }
        
        let error: ResultError?
        let query: Query?
        
        public struct Notification: Codable, Hashable {
            
            struct Timestamp: Codable, Hashable {
                let utciso8601: String
                let utcunix: String
                
                enum CodingKeys: String, CodingKey {
                    case utciso8601
                    case utcunix
                }
                
                init(from decoder: Decoder) throws {
                    let values = try decoder.container(keyedBy: CodingKeys.self)
                    utciso8601 = try values.decode(String.self, forKey: .utciso8601)
                    do {
                        utcunix = String(try values.decode(Int.self, forKey: .utcunix))
                    } catch {
                        utcunix = try values.decode(String.self, forKey: .utcunix)
                    }
                }
            }
            struct Agent: Codable, Hashable {
                let id: String?
                let name: String?
                
                enum CodingKeys: String, CodingKey {
                    case id
                    case name
                }
                
                init(from decoder: Decoder) throws {
                    let values = try decoder.container(keyedBy: CodingKeys.self)
                    name = try values.decode(String.self, forKey: .name)
                    do {
                        id = String(try values.decode(Int.self, forKey: .id))
                    } catch {
                        id = try values.decode(String.self, forKey: .id)
                    }
                }
            }
            struct Title: Codable, Hashable {
                let full: String?
                let namespace: String?
                let namespaceKey: Int?
                let text: String?
                
                enum CodingKeys: String, CodingKey {
                    case full
                    case namespace
                    case namespaceKey = "namespace-key"
                    case text
                }
            }
            
            struct Message: Codable, Hashable {
                let header: String?
                let body: String?
                let links: RemoteNotificationLinks?
            }
            
            let wiki: String
            let id: String
            let type: String
            let category: String
            let section: String
            let timestamp: Timestamp
            let title: Title?
            let agent: Agent?
            let readString: String?
            let revisionID: String?
            let message: Message?
            let sources: [String: [String: String]]?

            enum CodingKeys: String, CodingKey {
                case wiki
                case id
                case type
                case category
                case section
                case timestamp
                case title = "title"
                case agent
                case readString = "read"
                case revisionID = "revid"
                case message = "*"
                case sources
            }
            
            public func hash(into hasher: inout Hasher) {
                hasher.combine(key)
            }
            
            public static func ==(lhs: Notification, rhs: Notification) -> Bool {
                return lhs.key == rhs.key &&
                    lhs.readString == rhs.readString
            }

            public init(from decoder: Decoder) throws {
                let values = try decoder.container(keyedBy: CodingKeys.self)
                wiki = try values.decode(String.self, forKey: .wiki)
                do {
                    id = String(try values.decode(Int.self, forKey: .id))
                } catch {
                    id = try values.decode(String.self, forKey: .id)
                }
                
                type = try values.decode(String.self, forKey: .type)
                category = try values.decode(String.self, forKey: .category)
                section = try values.decode(String.self, forKey: .section)
                timestamp = try values.decode(Timestamp.self, forKey: .timestamp)
                title = try? values.decode(Title.self, forKey: .title)
                agent = try? values.decode(Agent.self, forKey: .agent)
                readString = try? values.decode(String.self, forKey: .readString)
                
                if let intRevID = try? values.decode(Int.self, forKey: .revisionID) {
                    revisionID = String(intRevID)
                } else {
                    revisionID = (try? values.decode(String.self, forKey: .revisionID)) ?? nil
                }
                
                message = try? values.decode(Message.self, forKey: .message)
                sources = try? values.decode([String: [String: String]].self, forKey: .sources)
            }
        }
        
        
    }

    // MARK: Decodable: MarkReadResult

    private struct MarkReadResult: Decodable {
        let query: Query?
        let error: ResultError?

        var succeeded: Bool {
            return query?.markAsRead?.result == .success
        }

        struct Query: Decodable {
            let markAsRead: MarkedAsRead?

            enum CodingKeys: String, CodingKey {
                case markAsRead = "echomarkread"
            }
        }
        struct MarkedAsRead: Decodable {
            let result: Result?
        }
        enum Result: String, Decodable {
            case success
        }
    }

    enum MarkReadError: LocalizedError {
        case noResult
        case unknown
        case multiple([Error])
        
        var errorDescription: String? {
            
            switch self {
            case .multiple(let errors):
                if let firstError = errors.first {
                    return (firstError as NSError).alertMessage()
                }
            case .noResult, .unknown:
                return RequestError.unexpectedResponse.errorDescription ?? CommonStrings.genericErrorDescription
            }
            
            return CommonStrings.genericErrorDescription
        }
    }
    
    // MARK: Decodable: MarkSeenResult
    
    struct MarkSeenResult: Decodable {
        let query: Query?
        let error: ResultError?
        
        var succeeded: Bool {
            return query?.markAsSeen?.result == .success
        }
        
        struct Query: Decodable {
            let markAsSeen: MarkAsSeen?
            
            enum CodingKeys: String, CodingKey {
                case markAsSeen = "echomarkseen"
            }
        }
        
        struct MarkAsSeen: Decodable {
            let result: Result?
        }
        
        enum Result: String, Decodable {
            case success
        }
    }
    
    enum MarkSeenError: LocalizedError {
        case noResult
        case unknown
        
        var errorDescription: String? {
            return RequestError.unexpectedResponse.errorDescription ?? CommonStrings.genericErrorDescription
        }
    }
    
    // MARK: Public
    
    public func getUnreadPushNotifications(from project: WikimediaProject, completion: @escaping (Set<NotificationsResult.Notification>, Error?) -> Void) {
        let completion: (NotificationsResult?, URLResponse?, Error?) -> Void = { result, _, error in
            guard error == nil else {
                completion([], error)
                return
            }
            let result = result?.query?.notifications?.list ?? []
            completion(Set(result), nil)
        }
        request(project: project, queryParameters: Query.notifications(limit: .max, filter: .unread, notifierType: .push, continueId: nil), completion: completion)
    }
    
    func getAllNotifications(from project: WikimediaProject, needsCrossWikiSummary: Bool = false, filter: Query.Filter = .none, continueId: String?, completion: @escaping (NotificationsResult.Query.Notifications?, Error?) -> Void) {
        let completion: (NotificationsResult?, URLResponse?, Error?) -> Void = { result, _, error in
            guard error == nil else {
                completion(nil, error)
                return
            }
            completion(result?.query?.notifications, result?.error)
        }
        
        request(project: project, queryParameters: Query.notifications(from: [project], limit: .max, filter: filter, needsCrossWikiSummary: needsCrossWikiSummary, continueId: continueId), completion: completion)
    }
    
    func markAllAsSeen(project: WikimediaProject, completion: @escaping ((Result<Void, Error>) -> Void)) {
        request(project: project, queryParameters: Query.markAllAsSeen(project: project), method: .post) { (result: MarkSeenResult?, _, error) in
            if let error = error {
                completion(.failure(error))
                return
            }
            
            guard let result = result else {
                assertionFailure("Expected result")
                completion(.failure(MarkSeenError.noResult))
                return
            }
            
            if let error = result.error {
                completion(.failure(error))
                return
            }
            
            if !result.succeeded {
                completion(.failure(MarkSeenError.unknown))
                return
            }
            completion(.success(()))
        }
    }
    
    func markAllAsRead(project: WikimediaProject, completion: @escaping (Error?) -> Void) {
        
        request(project: project, queryParameters: Query.markAllAsRead(project: project), method: .post) { (result: MarkReadResult?, _, error) in
            if let error = error {
                completion(error)
                return
            }
            guard let result = result else {
                assertionFailure("Expected result; make sure MarkReadResult maps the expected result correctly")
                completion(MarkReadError.noResult)
                return
            }
            if let error = result.error {
                completion(error)
                return
            }
            if !result.succeeded {
                completion(MarkReadError.unknown)
                return
            }
            completion(nil)
        }
    }

    func markAsReadOrUnread(project: WikimediaProject, identifierGroups: Set<RemoteNotification.IdentifierGroup>, shouldMarkRead: Bool, completion: @escaping (Error?) -> Void) {
        let maxNumberOfNotificationsPerRequest = 50
        let identifierGroups = Array(identifierGroups)
        let split = identifierGroups.chunked(into: maxNumberOfNotificationsPerRequest)

        split.asyncCompactMap({ (identifierGroups, completion: @escaping (Error?) -> Void) in
            request(project: project, queryParameters: Query.markAsReadOrUnread(identifierGroups: identifierGroups, shouldMarkRead: shouldMarkRead), method: .post) { (result: MarkReadResult?, _, error) in
                if let error = error {
                    completion(error)
                    return
                }
                guard let result = result else {
                    assertionFailure("Expected result; make sure MarkReadResult maps the expected result correctly")
                    completion(MarkReadError.noResult)
                    return
                }
                if let error = result.error {
                    completion(error)
                    return
                }
                if !result.succeeded {
                    completion(MarkReadError.unknown)
                    return
                }
                completion(nil)
            }
        }) { (errors) in
            if errors.isEmpty {
                completion(nil)
            } else {
                DDLogError("\(errors.count) of \(split.count) mark as read requests failed")
                completion(MarkReadError.multiple(errors))
            }
        }
    }
    
    // MARK: Private

    private func request<T: Decodable>(project: WikimediaProject?, queryParameters: Query.Parameters?, method: Session.Request.Method = .get, completion: @escaping (T?, URLResponse?, Error?) -> Void) {

        guard let url = project?.mediaWikiAPIURL(configuration: configuration, queryParameters: queryParameters) else {
            completion(nil, nil, RequestError.invalidParameters)
            return
        }
        
        if method == .get {
            session.jsonDecodableTask(with: url, method: .get, completionHandler: completion)
        } else {
            requestMediaWikiAPIAuthToken(for:url, type: .csrf) { (result) in
                switch result {
                case .failure(let error):
                    completion(nil, nil, error)
                case .success(let token):
                    self.session.jsonDecodableTask(with: url, method: method, bodyParameters: ["token": token.value], bodyEncoding: .form, completionHandler: completion)
                }
            }
        }
    }

    // MARK: Query parameters

    struct Query {
        typealias Parameters = [String: Any]
        
        enum Limit {
            case max
            case numeric(Int)
            
            var value: String {
                switch self {
                case .max:
                    return "max"
                case .numeric(let number):
                    return "\(number)"
                }
            }
        }
        
        enum Filter: String {
            case read = "read"
            case unread = "!read"
            case none = "read|!read"
        }
        
        enum NotifierType: String {
            case web
            case push
            case email
        }
        
        static func notifications(from projects: [WikimediaProject] = [], limit: Limit = .max, filter: Filter = .none, notifierType: NotifierType? = nil, needsCrossWikiSummary: Bool = false, continueId: String?) -> Parameters {
            var dictionary: [String: Any] = ["action": "query",
                                             "format": "json",
                                             "formatversion": "2",
                                             "notformat": "model",
                                             "meta": "notifications",
                                             "notlimit": limit.value,
                                             "notfilter": filter.rawValue]

            if let continueId = continueId {
                dictionary["notcontinue"] = continueId
            }
            
            if let notifierType = notifierType {
                dictionary["notnotifiertypes"] = notifierType.rawValue
            }
            
            if needsCrossWikiSummary {
                dictionary["notcrosswikisummary"] = 1
            }
            
            let wikis = projects.map { $0.notificationsApiWikiIdentifier }
            dictionary["notwikis"] = wikis.isEmpty ? "*" : wikis.joined(separator: "|")
            
            return dictionary
        }
        
        static func markAsReadOrUnread(identifierGroups: [RemoteNotification.IdentifierGroup], shouldMarkRead: Bool) -> Parameters? {
            let IDs = identifierGroups.compactMap { $0.id }
            
            var dictionary = ["action": "echomarkread",
                              "format": "json"]
            if shouldMarkRead {
                dictionary["list"] = IDs.joined(separator: "|")
            } else {
                dictionary["unreadlist"] = IDs.joined(separator: "|")
            }
            
            return dictionary
        }
        
        static func markAllAsRead(project: WikimediaProject) -> Parameters? {
            let dictionary = ["action": "echomarkread",
                              "all": "true",
                              "wikis": project.notificationsApiWikiIdentifier,
                              "format": "json"]
            return dictionary
        }
        
        static func markAllAsSeen(project: WikimediaProject) -> Parameters? {
            let dictionary = ["action": "echomarkseen",
                              "wikis": project.notificationsApiWikiIdentifier,
                              "format": "json",
                              "type": "all"]
            return dictionary
        }
    }
}

extension RemoteNotificationsAPIController.ResultError: LocalizedError {
    var errorDescription: String? {
        return info
    }
}

// MARK: Public Notification Extensions

public extension RemoteNotificationsAPIController.NotificationsResult.Notification {

    var key: String {
        return "\(wiki)-\(id)"
    }
    
    var date: Date? {
        return DateFormatter.wmf_iso8601()?.date(from: timestamp.utciso8601)
    }
    
    var pushContentText: String? {
        return self.message?.header?.removingHTML
    }
    
    var namespaceKey: Int? {
        return self.title?.namespaceKey
    }
    
    var titleFull: String? {
        return self.title?.full
    }
    
    func isNewerThan(timeAgo: TimeInterval) -> Bool {
        guard let date = date else {
            return false
        }

        return date > Date().addingTimeInterval(-timeAgo)
    }
    
    var namespace: PageNamespace? {
        return PageNamespace(namespaceValue: title?.namespaceKey)
    }
}

// MARK: Test Helpers

#if TEST

extension RemoteNotificationsAPIController.NotificationsResult.Notification {
    
    init?(project: WikimediaProject, titleText: String, titleNamespace: PageNamespace, remoteNotificationType: RemoteNotificationType, date: Date, customID: String? = nil) {
        
        switch remoteNotificationType {
        case .userTalkPageMessage:
            self.category = "edit-user-talk"
            self.type = "edit-user-talk"
            self.section = "alert"
        case .editReverted:
            self.category = "reverted"
            self.type = "reverted"
            self.section = "alert"
        default:
            assertionFailure("Haven't set up test models for this type.")
            return nil
        }
        
        self.wiki = project.notificationsApiWikiIdentifier

        let identifier = customID ?? UUID().uuidString
        self.id = identifier
        
        let timestamp = Timestamp(date: date)
        self.timestamp = timestamp
        self.title = Title(titleText: titleText, titleNamespace: titleNamespace)
        self.agent = Agent()
        
        self.revisionID = nil
        self.readString = nil
       
        self.message = Message(identifier: identifier)
        self.sources = nil
    }
}

extension RemoteNotificationsAPIController.NotificationsResult.Notification.Timestamp {
    init(date: Date) {
        let dateString8601 = DateFormatter.wmf_iso8601().string(from: date)
        let unixTimeInterval = date.timeIntervalSince1970
        self.utciso8601 = dateString8601
        self.utcunix = String(unixTimeInterval)
    }
}

extension RemoteNotificationsAPIController.NotificationsResult.Notification.Title {
    init(titleText: String, titleNamespace: PageNamespace) {
        
        let namespaceText = titleNamespace.canonicalName
        self.full = "\(namespaceText):\(titleText)"
        self.namespace = titleNamespace.canonicalName.denormalizedPageTitle
        self.namespaceKey = titleNamespace.rawValue
        self.text = titleText
    }
}

extension RemoteNotificationsAPIController.NotificationsResult.Notification.Agent {
    init() {
        self.id = String(12345)
        self.name = "Test Agent Name"
    }
}

extension RemoteNotificationsAPIController.NotificationsResult.Notification.Message {
    init(identifier: String) {
        self.header = "\(identifier)"
        self.body = "Test body text for identifier: \(identifier)"
        let primaryLink = RemoteNotificationLink(type: nil, url: URL(string:"https://en.wikipedia.org/wiki/Cat")!, label: "Label for primary link")
        self.links = RemoteNotificationLinks(primary: primaryLink, secondary: nil, legacyPrimary: primaryLink)
    }
}

#endif
