//
//  Client.swift
//  Pachyderm
//
//  Created by Shadowfacts on 9/8/18.
//  Copyright © 2018 Shadowfacts. All rights reserved.
//

import Foundation
import WebURL

/**
 The base Mastodon API client.
 */
public class Client {
    
    public typealias Callback<Result: Decodable> = (Response<Result>) -> Void
    
    let baseURL: URL
    let session: URLSession
    
    public var accessToken: String?
    
    public var appID: String?
    public var clientID: String?
    public var clientSecret: String?
    
    public var timeoutInterval: TimeInterval = 60
    
    static let decoder: JSONDecoder = {
        let decoder = JSONDecoder()
        
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
        formatter.timeZone = TimeZone(abbreviation: "UTC")
        formatter.locale = Locale(identifier: "en_US_POSIX")
        let iso8601 = ISO8601DateFormatter()
        decoder.dateDecodingStrategy = .custom({ (decoder) in
            let container = try decoder.singleValueContainer()
            let str = try container.decode(String.self)
            // for the next time mastodon accidentally changes date formats >.>
            if let date = formatter.date(from: str) {
                return date
            } else if let date = iso8601.date(from: str) {
                return date
            } else {
                throw DecodingError.typeMismatch(Date.self, .init(codingPath: container.codingPath, debugDescription: "unexpected date format"))
            }
        })
        
        return decoder
    }()
    
    static let encoder: JSONEncoder = {
        let encoder = JSONEncoder()
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
        formatter.timeZone = TimeZone(abbreviation: "UTC")
        formatter.locale = Locale(identifier: "en_US_POSIX")
        encoder.dateEncodingStrategy = .formatted(formatter)
        return encoder
    }()
    
    public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.accessToken = accessToken
        self.session = session
    }
    
    @discardableResult
    public func run<Result>(_ request: Request<Result>, completion: @escaping Callback<Result>) -> URLSessionTask? {
        guard let urlRequest = createURLRequest(request: request) else {
            completion(.failure(Error(request: request, type: .invalidRequest)))
            return nil
        }
        
        let task = session.dataTask(with: urlRequest) { data, response, error in
            if let error = error {
                completion(.failure(Error(request: request, type: .networkError(error))))
                return
            }
            guard let data = data,
                let response = response as? HTTPURLResponse else {
                    completion(.failure(Error(request: request, type: .invalidResponse)))
                    return
            }
            guard response.statusCode == 200 || request.additionalAcceptableHTTPCodes.contains(response.statusCode) else {
                let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
                let type: ErrorType = mastodonError.flatMap { .mastodonError(response.statusCode, $0.description) } ?? .unexpectedStatus(response.statusCode)
                completion(.failure(Error(request: request, type: type)))
                return
            }
            let result: Result
            do {
                result = try Client.decoder.decode(Result.self, from: data)
            } catch {
                completion(.failure(Error(request: request, type: .invalidModel(error))))
                return
            }
            let pagination = response.allHeaderFields["Link"].flatMap { $0 as? String }.flatMap(Pagination.init)
            
            completion(.success(result, pagination))
        }
        task.resume()
        return task
    }
    
    @discardableResult
    public func run<Result: Sendable>(_ request: Request<Result>) async throws -> (Result, Pagination?) {
        return try await withCheckedThrowingContinuation { continuation in
            run(request) { response in
                switch response {
                case .failure(let error):
                    continuation.resume(throwing: error)
                case .success(let result, let pagination):
                    continuation.resume(returning: (result, pagination))
                }
            }
        }
    }
    
    func createURLRequest<Result>(request: Request<Result>) -> URLRequest? {
        guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil }
        components.path = request.endpoint.path
        components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems
        guard let url = components.url else { return nil }
        var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval)
        urlRequest.httpMethod = request.method.name
        urlRequest.httpBody = request.body.data
        urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
        for (name, value) in request.headers {
            urlRequest.setValue(value, forHTTPHeaderField: name)
        }
        if let mimeType = request.body.mimeType {
            urlRequest.setValue(mimeType, forHTTPHeaderField: "Content-Type")
        }
        if let accessToken = accessToken {
            urlRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
            // We consider authenticated requests to be user-initiated.
            urlRequest.attribution = .user
        }
        return urlRequest
    }
    
    // MARK: - Authorization
    public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
        let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
                "client_name" => name,
                "redirect_uris" => redirectURI,
                "scopes" => scopes.scopeString,
                "website" => website?.absoluteString
            ]))
        run(request) { result in
            defer { completion(result) }
            guard case let .success(application, _) = result else { return }
            
            self.appID = application.id
            self.clientID = application.clientID
            self.clientSecret = application.clientSecret
        }
    }
    
    public func getAccessToken(authorizationCode: String, redirectURI: String, scopes: [Scope], completion: @escaping Callback<LoginSettings>) {
        let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
                "client_id" => clientID,
                "client_secret" => clientSecret,
                "grant_type" => "authorization_code",
                "code" => authorizationCode,
                "redirect_uri" => redirectURI,
                "scope" => scopes.scopeString,
            ]))
        run(request) { result in
            defer { completion(result) }
            guard case let .success(loginSettings, _) = result else { return }
            
            self.accessToken = loginSettings.accessToken
        }
    }
    
    public func revokeAccessToken() async throws {
        guard let accessToken else {
            return
        }
        let request = Request<Empty>(method: .post, path: "/oauth/revoke", body: ParametersBody([
            "token" => accessToken,
            "client_id" => clientID!,
            "client_secret" => clientSecret!,
        ]))
        return try await withCheckedThrowingContinuation({ continuation in
            self.run(request) { response in
                switch response {
                case .failure(let error):
                    continuation.resume(throwing: error)
                case .success(_, _):
                    continuation.resume()
                }
            }
        })
    }
    
    public func nodeInfo(completion: @escaping Callback<NodeInfo>) {
        let wellKnown = Request<WellKnown>(method: .get, path: "/.well-known/nodeinfo")
        run(wellKnown) { result in
            switch result {
            case let .failure(error):
                completion(.failure(error))
                
            case let .success(wellKnown, _):
                if let url = wellKnown.links.first(where: { $0.rel == "http://nodeinfo.diaspora.software/ns/schema/2.0" }),
                   let href = WebURL(url.href),
                   href.host == WebURL(self.baseURL)?.host {
                    let nodeInfo = Request<NodeInfo>(method: .get, path: Endpoint(stringLiteral: href.path))
                    self.run(nodeInfo, completion: completion)
                }
            }
        }
    }
    
    // MARK: - Self
    public static func getSelfAccount() -> Request<Account> {
        return Request<Account>(method: .get, path: "/api/v1/accounts/verify_credentials")
    }
    
    public static func getFavourites(range: RequestRange = .default) -> Request<[Status]> {
        var request = Request<[Status]>(method: .get, path: "/api/v1/favourites")
        request.range = range
        return request
    }
    
    public static func getRelationships(accounts: [String]? = nil) -> Request<[Relationship]> {
        return Request<[Relationship]>(method: .get, path: "/api/v1/accounts/relationships", queryParameters: "id" => accounts)
    }
    
    public static func getInstance() -> Request<Instance> {
        return Request<Instance>(method: .get, path: "/api/v1/instance")
    }
    
    public static func getCustomEmoji() -> Request<[Emoji]> {
        return Request<[Emoji]>(method: .get, path: "/api/v1/custom_emojis")
    }
    
    public static func getPreferences() -> Request<Preferences> {
        return Request(method: .get, path: "/api/v1/preferences")
    }
    
    // MARK: - Accounts
    public static func getAccount(id: String) -> Request<Account> {
        return Request<Account>(method: .get, path: "/api/v1/accounts/\(id)")
    }
    
    public static func searchForAccount(query: String, limit: Int? = nil, following: Bool? = nil) -> Request<[Account]> {
        return Request<[Account]>(method: .get, path: "/api/v1/accounts/search", queryParameters: [
                "q" => query,
                "limit" => limit,
                "following" => following
            ])
    }
    
    // MARK: - Blocks
    public static func getBlocks() -> Request<[Account]> {
        return Request<[Account]>(method: .get, path: "/api/v1/blocks")
    }
    
    public static func getDomainBlocks() -> Request<[String]> {
        return Request<[String]>(method: .get, path: "api/v1/domain_blocks")
    }
    
    public static func block(domain: String) -> Request<Empty> {
        return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
                "domain" => domain
            ]))
    }
    
    public static func unblock(domain: String) -> Request<Empty> {
        return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
                "domain" => domain
            ]))
    }
    
    // MARK: - Filters
    public static func getFiltersV1() -> Request<[FilterV1]> {
        return Request<[FilterV1]>(method: .get, path: "/api/v1/filters")
    }
    
    public static func createFilterV1(phrase: String, context: [FilterV1.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresIn: TimeInterval? = nil) -> Request<FilterV1> {
        return Request<FilterV1>(method: .post, path: "/api/v1/filters", body: ParametersBody([
                "phrase" => phrase,
                "irreversible" => irreversible,
                "whole_word" => wholeWord,
                "expires_in" => expiresIn,
            ] + "context" => context.contextStrings))
    }
    
    public static func getFilterV1(id: String) -> Request<FilterV1> {
        return Request<FilterV1>(method: .get, path: "/api/v1/filters/\(id)")
    }
    
    public static func getFiltersV2() -> Request<[FilterV2]> {
        return Request(method: .get, path: "/api/v2/filters")
    }
    
    // MARK: - Follows
    public static func getFollowRequests(range: RequestRange = .default) -> Request<[Account]> {
        var request = Request<[Account]>(method: .get, path: "/api/v1/follow_requests")
        request.range = range
        return request
    }
    
    public static func getFollowSuggestions() -> Request<[Account]> {
        return Request<[Account]>(method: .get, path: "/api/v1/suggestions")
    }
    
    public static func followRemote(acct: String) -> Request<Account> {
        return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
    }
    
    public static func getFollowedHashtags() -> Request<[Hashtag]> {
        return Request(method: .get, path: "/api/v1/followed_tags")
    }
    
    // MARK: - Lists
    public static func getLists() -> Request<[List]> {
        return Request<[List]>(method: .get, path: "/api/v1/lists")
    }
    
    public static func getList(id: String) -> Request<List> {
        return Request<List>(method: .get, path: "/api/v1/lists/\(id)")
    }
    
    public static func createList(title: String) -> Request<List> {
        return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
    }
    
    // MARK: - Media
    public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
        return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
                "description" => description,
                "focus" => focus
            ], attachment))
    }
    
    public static func updateAttachment(id: String, description: String?, focus: (Float, Float)?) -> Request<Attachment> {
        return Request(method: .put, path: "/api/v1/media/\(id)", body: FormDataBody([
            "description" => description,
            "focus" => focus
        ], nil))
    }
    
    // MARK: - Mutes
    public static func getMutes(range: RequestRange) -> Request<[Account]> {
        var request = Request<[Account]>(method: .get, path: "/api/v1/mutes")
        request.range = range
        return request
    }
    
    // MARK: - Notifications
    public static func getNotifications(allowedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
        var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
            "types" => allowedTypes.map { $0.rawValue }
        )
        request.range = range
        return request
    }
    
    public static func getNotifications(excludedTypes: [Notification.Kind], range: RequestRange = .default) -> Request<[Notification]> {
        var request = Request<[Notification]>(method: .get, path: "/api/v1/notifications", queryParameters:
            "exclude_types" => excludedTypes.map { $0.rawValue }
        )
        request.range = range
        return request
    }
    
    public static func clearNotifications() -> Request<Empty> {
        return Request<Empty>(method: .post, path: "/api/v1/notifications/clear")
    }
    
    // MARK: - Reports
    public static func getReports() -> Request<[Report]> {
        return Request<[Report]>(method: .get, path: "/api/v1/reports")
    }
    
    public static func report(
        account: String,
        statuses: [String],
        comment: String,
        forward: Bool,
        category: String,
        ruleIDs: [String]
    ) -> Request<Report> {
        return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
                "account_id" => account,
                "comment" => comment,
                "forward" => forward,
                "category" => category,
            ] + "status_ids" => statuses + "rule_ids" => ruleIDs))
    }
    
    // MARK: - Search
    public static func search(query: String, types: [SearchResultType]? = nil, resolve: Bool? = nil, limit: Int? = nil, following: Bool? = nil) -> Request<SearchResults> {
        return Request<SearchResults>(method: .get, path: "/api/v2/search", queryParameters: [
                "q" => query,
                "resolve" => resolve,
                "limit" => limit,
                "following" => following,
        ] + "types" => types?.map { $0.rawValue })
    }
    
    // MARK: - Statuses
    public static func getStatus(id: String) -> Request<Status> {
        return Request<Status>(method: .get, path: "/api/v1/statuses/\(id)")
    }
    
    public static func createStatus(text: String,
                                    contentType: StatusContentType = .plain,
                                    inReplyTo: String? = nil,
                                    mediaIDs: [String]? = nil,
                                    sensitive: Bool? = nil,
                                    spoilerText: String? = nil,
                                    visibility: String? = nil,
                                    language: String? = nil, // language supported by mastodon and akkoma
                                    pollOptions: [String]? = nil,
                                    pollExpiresIn: Int? = nil,
                                    pollMultiple: Bool? = nil,
                                    localOnly: Bool? = nil, /* hometown only, not glitch */
                                    idempotencyKey: String) -> Request<Status> {
        var req = Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
            "status" => text,
            "content_type" => contentType.mimeType,
            "in_reply_to_id" => inReplyTo,
            "sensitive" => sensitive,
            "spoiler_text" => spoilerText,
            "visibility" => visibility,
            "language" => language,
            "poll[expires_in]" => pollExpiresIn,
            "poll[multiple]" => pollMultiple,
            "local_only" => localOnly,
        ] + "media_ids" => mediaIDs + "poll[options]" => pollOptions))
        req.headers["Idempotency-Key"] = idempotencyKey
        return req
    }
    
    public static func editStatus(
        id: String,
        text: String,
        contentType: StatusContentType = .plain,
        spoilerText: String?,
        sensitive: Bool,
        language: String?,
        mediaIDs: [String],
        mediaAttributes: [EditStatusMediaAttributes],
        poll: EditPollParameters?
    ) -> Request<Status> {
        let params = EditStatusParameters(
            id: id,
            text: text,
            contentType: contentType,
            spoilerText: spoilerText,
            sensitive: sensitive,
            language: language,
            mediaIDs: mediaIDs,
            mediaAttributes: mediaAttributes,
            poll: poll
        )
        return Request(method: .put, path: "/api/v1/statuses/\(id)", body: JsonBody(params))
    }
    
    // MARK: - Timelines
    public static func getStatuses(timeline: Timeline, range: RequestRange = .default) -> Request<[Status]> {
        return timeline.request(range: range)
    }
    
    
    // MARK: - Bookmarks
    public static func getBookmarks(range: RequestRange = .default) -> Request<[Status]> {
        var request = Request<[Status]>(method: .get, path: "/api/v1/bookmarks")
        request.range = range
        return request
    }
    
    // MARK: - Instance
    public static func getTrendingHashtagsDeprecated(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
        var parameters: [Parameter] = []
        if let limit {
            parameters.append("limit" => limit)
        }
        if let offset {
            parameters.append("offset" => offset)
        }
        return Request<[Hashtag]>(method: .get, path: "/api/v1/trends", queryParameters: parameters)
    }
    
    public static func getTrendingHashtags(limit: Int? = nil, offset: Int? = nil) -> Request<[Hashtag]> {
        var parameters: [Parameter] = []
        if let limit {
            parameters.append("limit" => limit)
        }
        if let offset {
            parameters.append("offset" => offset)
        }
        return Request<[Hashtag]>(method: .get, path: "/api/v1/trends/tags", queryParameters: parameters)
    }
    
    public static func getTrendingStatuses(limit: Int? = nil, offset: Int? = nil) -> Request<[Status]> {
        var parameters: [Parameter] = []
        if let limit {
            parameters.append("limit" => limit)
        }
        if let offset {
            parameters.append("offset" => offset)
        }
        return Request(method: .get, path: "/api/v1/trends/statuses", queryParameters: parameters)
    }
    
    public static func getTrendingLinks(limit: Int? = nil, offset: Int? = nil) -> Request<[Card]> {
        var parameters: [Parameter] = []
        if let limit {
            parameters.append("limit" => limit)
        }
        if let offset {
            parameters.append("offset" => offset)
        }
        return Request(method: .get, path: "/api/v1/trends/links", queryParameters: parameters)
    }
    
    public static func getFeaturedProfiles(local: Bool, order: DirectoryOrder, offset: Int? = nil, limit: Int? = nil) -> Request<[Account]> {
        var parameters = [
            "order" => order.rawValue,
            "local" => local,
        ]
        if let offset = offset {
            parameters.append("offset" => offset)
        }
        if let limit = limit {
            parameters.append("limit" => limit)
        }
        return Request<[Account]>(method: .get, path: "/api/v1/directory", queryParameters: parameters)
    }
    
    public static func getSuggestions(limit: Int?) -> Request<[Suggestion]> {
        return Request(method: .get, path: "/api/v2/suggestions", queryParameters: [
            "limit" => limit,
        ])
    }
    
    // MARK: - Hashtags
    /// Requires Mastodon 4.0.0+
    public static func getHashtag(name: String) -> Request<Hashtag> {
        return Request(method: .get, path: "/api/v1/tags/\(name)")
    }

}

extension Client {
    public struct Error: LocalizedError, Sendable {
        public let requestMethod: Method
        public let requestEndpoint: Endpoint
        public let type: ErrorType
        
        #if DEBUG
        public static let debug = Error(request: Client.getStatuses(timeline: .home), type: .invalidResponse)
        #endif
        
        init<ResultType: Decodable>(request: Request<ResultType>, type: ErrorType) {
            self.requestMethod = request.method
            self.requestEndpoint = request.endpoint
            self.type = type
        }
        
        public var errorDescription: String? {
            switch type {
            case .networkError(let error):
                return "Network Error: \(error.localizedDescription)"
            // todo: support more status codes
            case .unexpectedStatus(413):
                return "HTTP 413: Payload Too Large"
            case .unexpectedStatus(429):
                return "HTTP 429: Rate Limit Exceeded"
            case .unexpectedStatus(let code):
                return "HTTP Code \(code)"
            case .invalidRequest:
                return "Invalid Request"
            case .invalidResponse:
                return "Invalid Response"
            case .invalidModel(_):
                return "Invalid Model"
            case .mastodonError(let code, let error):
                return "Server Error (\(code)): \(error)"
            }
        }
    }
    public enum ErrorType: LocalizedError, Sendable {
        case networkError(Swift.Error)
        case unexpectedStatus(Int)
        case invalidRequest
        case invalidResponse
        case invalidModel(Swift.Error)
        case mastodonError(Int, String)
    }
}
