import Foundation

public enum KraskaClientError: Error, LocalizedError {
    case missingSessionID
    case missingData(String)
    case invalidDownloadLink
    case apiError(String)

    public var errorDescription: String? {
        switch self {
        case .missingSessionID:
            return "Kra.sk login did not return session_id"
        case .missingData(let field):
            return "Missing expected field: \(field)"
        case .invalidDownloadLink:
            return "Kra.sk returned an invalid download URL"
        case .apiError(let message):
            return message
        }
    }
}

public actor KraskaClient {
    public struct Configuration: Sendable {
        public let baseURL: URL
        public let username: String
        public let password: String

        public init(
            baseURL: URL = URL(string: "https://api.kra.sk")!,
            username: String,
            password: String
        ) {
            self.baseURL = baseURL
            self.username = username
            self.password = password
        }
    }

    private let configuration: Configuration
    private let httpClient: HTTPClient
    private var sessionID: String?
    private let decoder = JSONDecoder()
    private let encoder = JSONEncoder()

    public init(
        configuration: Configuration,
        httpClient: HTTPClient = .init()
    ) {
        self.configuration = configuration
        self.httpClient = httpClient
    }

    public func login() async throws -> String {
        let payload: JSONObject = [
            "data": .object([
                "username": .string(configuration.username),
                "password": .string(configuration.password)
            ])
        ]
        let response = try await post(
            endpoint: "/api/user/login",
            payload: payload,
            includeSession: false
        )
        guard let value = response["session_id"]?.stringValue, !value.isEmpty else {
            throw KraskaClientError.missingSessionID
        }
        sessionID = value
        return value
    }

    public func currentSessionID() -> String? {
        sessionID
    }

    public func setSessionID(_ value: String?) {
        sessionID = value
    }

    public func userInfo() async throws -> JSONObject {
        _ = try await ensureSession()
        let response = try await post(endpoint: "/api/user/info", payload: [:])
        guard let data = response["data"]?.objectValue else {
            throw KraskaClientError.missingData("data")
        }
        return data
    }

    public func resolve(ident: String) async throws -> URL {
        _ = try await ensureSession()
        let response = try await post(
            endpoint: "/api/file/download",
            payload: [
                "data": .object(["ident": .string(ident)])
            ]
        )
        if let errorMessage = response["msg"]?.stringValue, response["error"] != nil {
            throw KraskaClientError.apiError(errorMessage)
        }
        guard
            let data = response["data"]?.objectValue,
            let link = data["link"]?.stringValue,
            let url = URL(string: link)
        else {
            throw KraskaClientError.invalidDownloadLink
        }
        return url
    }

    private func ensureSession() async throws -> String {
        if let sessionID, !sessionID.isEmpty {
            return sessionID
        }
        return try await login()
    }

    private func post(
        endpoint: String,
        payload: JSONObject,
        includeSession: Bool = true
    ) async throws -> JSONObject {
        var body = payload
        if includeSession {
            let sessionID = try await ensureSession()
            body["session_id"] = .string(sessionID)
        }

        let url = configuration.baseURL.appending(path: endpoint)
        let bodyData = try encoder.encode(body)
        let data = try await httpClient.send(
            url: url,
            method: .post,
            headers: ["Content-Type": "application/json"],
            body: bodyData
        )
        return try decoder.decode(JSONObject.self, from: data)
    }
}
