import Foundation

actor BookStackAPI {
    static let shared = BookStackAPI()

    private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
    private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
    private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""

    private let decoder: JSONDecoder = {
        let d = JSONDecoder()
        // BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
        let formatter = DateFormatter()
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(abbreviation: "UTC")
        d.dateDecodingStrategy = .formatted(formatter)
        return d
    }()

    // MARK: - Configuration

    func configure(serverURL: String, tokenId: String, tokenSecret: String) {
        var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
        while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
        self.serverURL = clean
        self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
        self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
        AppLog(.info, "API configured for \(clean)", category: "API")
    }

    func getServerURL() -> String { serverURL }

    // MARK: - Core Request (no body)

    func request<T: Decodable & Sendable>(
        endpoint: String,
        method: String = "GET"
    ) async throws -> T {
        try await performRequest(endpoint: endpoint, method: method, bodyData: nil)
    }

    // MARK: - Core Request (with body)

    func request<T: Decodable & Sendable, Body: Encodable & Sendable>(
        endpoint: String,
        method: String,
        body: Body
    ) async throws -> T {
        let bodyData = try JSONEncoder().encode(body)
        return try await performRequest(endpoint: endpoint, method: method, bodyData: bodyData)
    }

    // MARK: - Shared implementation

    private func performRequest<T: Decodable & Sendable>(
        endpoint: String,
        method: String,
        bodyData: Data?
    ) async throws -> T {
        guard !serverURL.isEmpty else {
            AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
            throw BookStackError.notAuthenticated
        }
        guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
            AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
            throw BookStackError.invalidURL
        }

        AppLog(.debug, "\(method) /api/\(endpoint)", category: "API")

        var req = URLRequest(url: url)
        req.httpMethod = method
        req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.timeoutInterval = 30

        if let bodyData {
            req.setValue("application/json", forHTTPHeaderField: "Content-Type")
            req.httpBody = bodyData
        }

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await URLSession.shared.data(for: req)
        } catch let urlError as URLError {
            let mapped: BookStackError
            switch urlError.code {
            case .timedOut:
                mapped = .timeout
            case .notConnectedToInternet, .networkConnectionLost:
                mapped = .networkUnavailable
            case .cannotFindHost, .dnsLookupFailed:
                mapped = .notReachable(host: serverURL)
            case .serverCertificateUntrusted, .serverCertificateHasBadDate,
                 .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
                mapped = .sslError
            default:
                mapped = .unknown(urlError.localizedDescription)
            }
            AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
            throw mapped
        }

        guard let http = response as? HTTPURLResponse else {
            AppLog(.error, "\(method) /api/\(endpoint) — invalid response type", category: "API")
            throw BookStackError.unknown("Invalid response type")
        }

        AppLog(.debug, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)", category: "API")

        guard (200..<300).contains(http.statusCode) else {
            let errorMessage = parseErrorMessage(from: data)
            let mapped: BookStackError
            switch http.statusCode {
            case 401: mapped = .unauthorized
            case 403: mapped = .forbidden
            case 404: mapped = .notFound(resource: "Resource")
            default:  mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
            }
            AppLog(.error, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)\(errorMessage.map { ": \($0)" } ?? "")", category: "API")
            throw mapped
        }

        // Handle 204 No Content
        if http.statusCode == 204 {
            guard let empty = EmptyResponse() as? T else {
                throw BookStackError.decodingError("Expected empty response for 204")
            }
            return empty
        }

        do {
            let result = try decoder.decode(T.self, from: data)
            return result
        } catch {
            AppLog(.error, "\(method) /api/\(endpoint) — decode error: \(error.localizedDescription)", category: "API")
            throw BookStackError.decodingError(error.localizedDescription)
        }
    }

    private func parseErrorMessage(from data: Data) -> String? {
        struct APIErrorEnvelope: Codable {
            struct Inner: Codable { let message: String? }
            let error: Inner?
        }
        return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
    }

    // MARK: - Shelves

    func fetchShelves(offset: Int = 0, count: Int = 100) async throws -> [ShelfDTO] {
        let response: PaginatedResponse<ShelfDTO> = try await request(
            endpoint: "shelves?offset=\(offset)&count=\(count)&sort=+name"
        )
        return response.data
    }

    func fetchShelf(id: Int) async throws -> ShelfBooksResponse {
        return try await request(endpoint: "shelves/\(id)")
    }

    func createShelf(name: String, shelfDescription: String) async throws -> ShelfDTO {
        struct Body: Encodable, Sendable {
            let name: String
            let description: String
        }
        return try await request(endpoint: "shelves", method: "POST",
                                 body: Body(name: name, description: shelfDescription))
    }

    /// Assign a list of book IDs to a shelf. Replaces the shelf's current book list.
    func updateShelfBooks(shelfId: Int, bookIds: [Int]) async throws {
        struct BookRef: Encodable, Sendable { let id: Int }
        struct Body: Encodable, Sendable { let books: [BookRef] }
        let _: ShelfDTO = try await request(
            endpoint: "shelves/\(shelfId)",
            method: "PUT",
            body: Body(books: bookIds.map { BookRef(id: $0) })
        )
    }

    func deleteShelf(id: Int) async throws {
        let _: EmptyResponse = try await request(endpoint: "shelves/\(id)", method: "DELETE")
    }

    // MARK: - Books

    func fetchBooks(offset: Int = 0, count: Int = 100) async throws -> [BookDTO] {
        let response: PaginatedResponse<BookDTO> = try await request(
            endpoint: "books?offset=\(offset)&count=\(count)&sort=+name"
        )
        return response.data
    }

    func fetchBook(id: Int) async throws -> BookDTO {
        return try await request(endpoint: "books/\(id)")
    }

    func createBook(name: String, bookDescription: String) async throws -> BookDTO {
        struct Body: Encodable, Sendable {
            let name: String
            let description: String
        }
        return try await request(endpoint: "books", method: "POST",
                                 body: Body(name: name, description: bookDescription))
    }

    func deleteBook(id: Int) async throws {
        let _: EmptyResponse = try await request(endpoint: "books/\(id)", method: "DELETE")
    }

    // MARK: - Chapters

    func fetchChapter(id: Int) async throws -> ChapterDTO {
        return try await request(endpoint: "chapters/\(id)")
    }

    func fetchChapters(bookId: Int) async throws -> [ChapterDTO] {
        let response: PaginatedResponse<ChapterDTO> = try await request(
            endpoint: "chapters?filter[book_id]=\(bookId)&count=100&sort=+priority"
        )
        return response.data
    }

    func createChapter(bookId: Int, name: String, chapterDescription: String) async throws -> ChapterDTO {
        struct Body: Encodable, Sendable {
            let bookId: Int
            let name: String
            let description: String
            enum CodingKeys: String, CodingKey {
                case bookId = "book_id"
                case name, description
            }
        }
        return try await request(endpoint: "chapters", method: "POST",
                                 body: Body(bookId: bookId, name: name, description: chapterDescription))
    }

    func deleteChapter(id: Int) async throws {
        let _: EmptyResponse = try await request(endpoint: "chapters/\(id)", method: "DELETE")
    }

    // MARK: - Pages

    func fetchPages(bookId: Int) async throws -> [PageDTO] {
        let response: PaginatedResponse<PageDTO> = try await request(
            endpoint: "pages?filter[book_id]=\(bookId)&count=100&sort=+priority"
        )
        return response.data
    }

    func fetchPage(id: Int) async throws -> PageDTO {
        return try await request(endpoint: "pages/\(id)")
    }

    func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
        struct TagBody: Encodable, Sendable {
            let name: String
            let value: String
        }
        struct Body: Encodable, Sendable {
            let bookId: Int
            let chapterId: Int?
            let name: String
            let markdown: String
            let tags: [TagBody]
            enum CodingKeys: String, CodingKey {
                case bookId = "book_id"
                case chapterId = "chapter_id"
                case name, markdown, tags
            }
        }
        return try await request(endpoint: "pages", method: "POST",
                                 body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown,
                                            tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
    }

    func updatePage(id: Int, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
        struct TagBody: Encodable, Sendable {
            let name: String
            let value: String
        }
        struct Body: Encodable, Sendable {
            let name: String
            let markdown: String
            let tags: [TagBody]
        }
        return try await request(endpoint: "pages/\(id)", method: "PUT",
                                 body: Body(name: name, markdown: markdown,
                                            tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
    }

    func deletePage(id: Int) async throws {
        let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
    }

    // MARK: - Tags

    func fetchTags(count: Int = 200) async throws -> [TagDTO] {
        let response: TagListResponseDTO = try await request(
            endpoint: "tags?count=\(count)&sort=+name"
        )
        return response.data
    }

    // MARK: - Search

    func search(query: String, type: SearchResultDTO.ContentType? = nil, tag: String? = nil) async throws -> SearchResponseDTO {
        var q = query
        if let type { q += " [type:\(type.rawValue)]" }
        if let tag  { q += " [tag:\(tag)]" }
        let encoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
        return try await request(endpoint: "search?query=\(encoded)")
    }

    // MARK: - Comments

    func fetchComments(pageId: Int) async throws -> [CommentDTO] {
        let list: PaginatedResponse<CommentSummaryDTO> = try await request(
            endpoint: "comments?filter[commentable_type]=page&filter[commentable_id]=\(pageId)&count=100"
        )
        // Fetch full detail for each comment in parallel to get html + user info
        return try await withThrowingTaskGroup(of: CommentDTO.self) { group in
            for summary in list.data {
                group.addTask {
                    try await self.request(endpoint: "comments/\(summary.id)")
                }
            }
            var results: [CommentDTO] = []
            for try await comment in group { results.append(comment) }
            return results.sorted { $0.createdAt < $1.createdAt }
        }
    }

    func postComment(pageId: Int, text: String) async throws {
        struct Body: Encodable, Sendable {
            let pageId: Int
            let html: String
            enum CodingKeys: String, CodingKey {
                case pageId = "page_id"
                case html
            }
        }
        // The POST response shape differs from CommentDTO — use EmptyResponse and discard it
        struct PostCommentResponse: Decodable, Sendable { let id: Int }
        // BookStack expects HTML content — wrap plain text in a paragraph tag
        let html = "<p>\(text.replacingOccurrences(of: "\n", with: "<br>"))</p>"
        let _: PostCommentResponse = try await request(endpoint: "comments", method: "POST",
                                                       body: Body(pageId: pageId, html: html))
    }

    func deleteComment(id: Int) async throws {
        let _: EmptyResponse = try await request(endpoint: "comments/\(id)", method: "DELETE")
    }

    // MARK: - System Info & Users

    /// Verify that the given URL is a reachable BookStack instance AND that the
    /// supplied token is valid — all in one request to GET /api/system.
    ///
    /// /api/system requires authentication and returns instance info on success:
    ///   200 → server reachable + token valid → returns APIInfo
    ///   401 → server reachable but token invalid
    ///   403 → server reachable but account lacks API permission
    ///   5xx / network error → server unreachable
    func verifyConnection(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
        // Normalise defensively in case caller skipped validateServerURL
        var cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
        while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }

        AppLog(.info, "Verifying connection to \(cleanURL)", category: "Auth")
        guard let requestURL = URL(string: "\(cleanURL)/api/system") else {
            AppLog(.error, "Invalid server URL: \(cleanURL)", category: "Auth")
            throw BookStackError.invalidURL
        }

        var req = URLRequest(url: requestURL)
        req.httpMethod = "GET"
        req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.timeoutInterval = 15

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await URLSession.shared.data(for: req)
        } catch let urlError as URLError {
            AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
            switch urlError.code {
            case .timedOut:
                throw BookStackError.timeout
            case .notConnectedToInternet, .networkConnectionLost:
                throw BookStackError.networkUnavailable
            case .serverCertificateUntrusted, .serverCertificateHasBadDate,
                 .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
                throw BookStackError.sslError
            default:
                throw BookStackError.notReachable(host: url)
            }
        }

        guard let http = response as? HTTPURLResponse else {
            throw BookStackError.notReachable(host: url)
        }

        AppLog(.debug, "GET /api/system → HTTP \(http.statusCode)", category: "Auth")

        switch http.statusCode {
        case 200:
            // Decode the system info
            if let info = try? decoder.decode(APIInfo.self, from: data) {
                AppLog(.info, "Connected to BookStack \(info.version) — \(info.appName ?? "unknown")", category: "Auth")
                return info
            }
            // Unexpected body but 200 — treat as success with unknown version
            AppLog(.warning, "GET /api/system returned 200 but unexpected body", category: "Auth")
            return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)

        case 401:
            throw BookStackError.unauthorized

        case 403:
            let msg = parseErrorMessage(from: data)
            AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
            throw BookStackError.forbidden

        case 404:
            // Old BookStack version without /api/system — fall back to /api/books probe
            AppLog(.warning, "/api/system not found (older BookStack?), falling back to /api/books", category: "Auth")
            return try await verifyViaBooks(url: url, tokenId: tokenId, tokenSecret: tokenSecret)

        case 500...:
            throw BookStackError.notReachable(host: url)

        default:
            throw BookStackError.httpError(statusCode: http.statusCode, message: nil)
        }
    }

    /// Fallback for older BookStack instances that don't have /api/system.
    private func verifyViaBooks(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
        guard let requestURL = URL(string: "\(url)/api/books?count=1") else {
            throw BookStackError.invalidURL
        }
        var req = URLRequest(url: requestURL)
        req.httpMethod = "GET"
        req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.timeoutInterval = 15

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await URLSession.shared.data(for: req)
        } catch let urlError as URLError {
            switch urlError.code {
            case .timedOut: throw BookStackError.timeout
            case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
            case .serverCertificateUntrusted, .serverCertificateHasBadDate,
                 .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
                throw BookStackError.sslError
            default: throw BookStackError.notReachable(host: url)
            }
        }

        guard let http = response as? HTTPURLResponse else {
            throw BookStackError.notReachable(host: url)
        }

        switch http.statusCode {
        case 200:
            AppLog(.info, "Fallback /api/books succeeded — BookStack confirmed", category: "Auth")
            return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)
        case 401: throw BookStackError.unauthorized
        case 403: throw BookStackError.forbidden
        default:
            let body = String(data: data, encoding: .utf8) ?? ""
            throw BookStackError.notReachable(host: "\(url) (HTTP \(http.statusCode): \(body.prefix(200)))")
        }
    }



    func verifyConnection() async throws -> APIInfo {
        return try await request(endpoint: "info")
    }

    func fetchCurrentUser() async throws -> UserDTO {
        // Fetch the users list to get current user info
        let response: PaginatedResponse<UserDTO> = try await request(endpoint: "users?count=1")
        guard let user = response.data.first else {
            throw BookStackError.notFound(resource: "Current user")
        }
        return user
    }

    // MARK: - Image Gallery

    /// Upload an image to the BookStack image gallery.
    /// - Parameters:
    ///   - data: Raw image bytes (JPEG or PNG)
    ///   - filename: Filename including extension, e.g. "photo.jpg"
    ///   - mimeType: e.g. "image/jpeg" or "image/png"
    ///   - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
    func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
        guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
        guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }

        let boundary = "Boundary-\(UUID().uuidString)"
        var body = Data()

        func appendField(_ name: String, _ value: String) {
            body.append("--\(boundary)\r\n".data(using: .utf8)!)
            body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
            body.append("\(value)\r\n".data(using: .utf8)!)
        }

        appendField("type", "gallery")
        appendField("uploaded_to", "\(pageId)")
        appendField("name", filename)

        // File field
        body.append("--\(boundary)\r\n".data(using: .utf8)!)
        body.append("Content-Disposition: form-data; name=\"image\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
        body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
        body.append(data)
        body.append("\r\n".data(using: .utf8)!)
        body.append("--\(boundary)--\r\n".data(using: .utf8)!)

        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
        req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
        req.setValue("application/json", forHTTPHeaderField: "Accept")
        req.httpBody = body
        req.timeoutInterval = 60

        AppLog(.info, "Uploading image '\(filename)' (\(data.count) bytes) to page \(pageId)", category: "API")

        let (responseData, response): (Data, URLResponse)
        do {
            (responseData, response) = try await URLSession.shared.data(for: req)
        } catch let urlError as URLError {
            throw BookStackError.unknown(urlError.localizedDescription)
        }

        guard let http = response as? HTTPURLResponse else {
            throw BookStackError.unknown("Invalid response")
        }

        AppLog(.debug, "POST /api/image-gallery → HTTP \(http.statusCode)", category: "API")

        guard http.statusCode == 200 else {
            let msg = parseErrorMessage(from: responseData)
            throw BookStackError.httpError(statusCode: http.statusCode, message: msg)
        }

        return try decoder.decode(ImageUploadResponse.self, from: responseData)
    }
}

// MARK: - Helper

nonisolated struct EmptyResponse: Codable, Sendable {
    init() {}
}

nonisolated struct ShelfBooksResponse: Codable, Sendable {
    let id: Int
    let name: String
    let books: [BookDTO]
}
