import CryptoKit
import Foundation

struct RadioBrowserClient: Sendable {
    var baseURL: URL
    var cacheDirectory: URL
    var session: URLSessionProtocol

    init(
        baseURL: URL = URL(string: "https://de1.api.radio-browser.info/json")!,
        cacheDirectory: URL,
        session: URLSessionProtocol = URLSession.shared
    ) {
        self.baseURL = baseURL
        self.cacheDirectory = cacheDirectory
        self.session = session
    }

    func topStations(count: Int = 100) async throws -> [RadioStation] {
        try await request("/stations/topvote/\(count)")
    }

    func germanStations() async throws -> [RadioStation] {
        try await request("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true")
    }

    func dutchStations() async throws -> [RadioStation] {
        try await request("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true")
    }

    func worldStations(count: Int = 200) async throws -> [RadioStation] {
        try await request("/stations?hidebroken=true&limit=\(count)&order=votes&reverse=true")
    }

    func stations(tag: String) async throws -> [RadioStation] {
        try await request("/stations/bytag/\(Self.percentEncodePathComponent(tag))")
    }

    func stations(countryCode: String) async throws -> [RadioStation] {
        try await request("/stations/bycountrycodeexact/\(countryCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())")
    }

    func search(_ query: String) async throws -> [RadioStation] {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
        let candidates = searchCandidates(for: trimmed)
        var firstError: Error?
        var combined: [RadioStation] = []
        var seenIDs = Set<String>()

        for candidate in candidates {
            do {
                let stations = try await request(searchEndpoint(name: candidate))
                for station in stations {
                    if seenIDs.insert(station.id).inserted {
                        combined.append(station)
                    }
                }
            } catch {
                if firstError == nil {
                    firstError = error
                }
            }
        }

        let ranked = rankSearchResults(combined, query: trimmed)
        if !ranked.isEmpty {
            return ranked
        }

        if let firstError {
            throw firstError
        }
        return []
    }

    func request(_ endpoint: String) async throws -> [RadioStation] {
        let cacheFile = cacheURL(for: endpoint)
        let requestURL = URL(string: baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpoint)!

        do {
            var request = URLRequest(url: requestURL)
            request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
            let (data, response) = try await session.data(for: request)
            if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
                throw URLError(.badServerResponse)
            }
            let stations = try Self.decodeStations(from: data)
            if !stations.isEmpty {
                try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
                try data.write(to: cacheFile, options: .atomic)
            }
            return stations
        } catch {
            if let cachedData = try? Data(contentsOf: cacheFile) {
                return try Self.decodeStations(from: cachedData)
            }
            throw error
        }
    }

    func cacheURL(for endpoint: String) -> URL {
        let digest = Insecure.MD5.hash(data: Data(endpoint.utf8))
        let hash = digest.map { String(format: "%02x", $0) }.joined()
        return cacheDirectory.appendingPathComponent("\(hash).json")
    }

    static func decodeStations(from data: Data) throws -> [RadioStation] {
        let decoder = JSONDecoder()
        return try decoder.decode([RadioStation].self, from: data)
            .filter { !$0.name.isEmpty && !$0.playbackURLString.isEmpty }
    }

    static func percentEncodePathComponent(_ value: String) -> String {
        var allowed = CharacterSet.urlPathAllowed
        allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
        return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
    }

    private func searchEndpoint(name: String) -> String {
        var components = URLComponents()
        components.path = "/stations/search"
        components.queryItems = [
            URLQueryItem(name: "name", value: name),
            URLQueryItem(name: "hidebroken", value: "true"),
            URLQueryItem(name: "limit", value: "200"),
            URLQueryItem(name: "order", value: "votes"),
            URLQueryItem(name: "reverse", value: "true")
        ]
        return components.string ?? "/stations/byname/\(Self.percentEncodePathComponent(name))"
    }

    private func searchCandidates(for query: String) -> [String] {
        guard !query.isEmpty else { return [] }

        var candidates = [query]
        let punctuation = CharacterSet.punctuationCharacters
        if query.rangeOfCharacter(from: punctuation) == nil {
            candidates.insert("\(query)!", at: 0)
            candidates.append("\(query).")
        }

        var seen = Set<String>()
        return candidates.filter { candidate in
            let key = candidate.lowercased()
            if seen.contains(key) { return false }
            seen.insert(key)
            return true
        }
    }

    private func rankSearchResults(_ stations: [RadioStation], query: String) -> [RadioStation] {
        let normalizedQuery = Self.normalizedSearchText(query)
        guard !normalizedQuery.isEmpty else { return stations }

        return stations
            .map { station -> (RadioStation, Int) in
                let normalizedName = Self.normalizedSearchText(station.name)
                let nameTokens = Self.normalizedSearchTokens(station.name)
                let allTokens = Self.normalizedSearchTokens(station.searchableText)
                let score: Int
                if normalizedName == normalizedQuery {
                    score = 0
                } else if nameTokens.contains(normalizedQuery) {
                    score = 1
                } else if nameTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
                    score = 2
                } else if allTokens.contains(normalizedQuery) {
                    score = 3
                } else if allTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
                    score = 4
                } else {
                    score = 5
                }
                return (station, score)
            }
            .filter { $0.1 < 5 }
            .sorted {
                if $0.1 != $1.1 { return $0.1 < $1.1 }
                if $0.0.votes != $1.0.votes { return $0.0.votes > $1.0.votes }
                return $0.0.name.localizedCaseInsensitiveCompare($1.0.name) == .orderedAscending
            }
            .map(\.0)
    }

    static func normalizedSearchText(_ value: String) -> String {
        value
            .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
            .components(separatedBy: CharacterSet.alphanumerics.inverted)
            .joined()
    }

    static func normalizedSearchTokens(_ value: String) -> [String] {
        value
            .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
            .components(separatedBy: CharacterSet.alphanumerics.inverted)
            .filter { !$0.isEmpty }
    }
}

protocol URLSessionProtocol: Sendable {
    func data(for request: URLRequest) async throws -> (Data, URLResponse)
}

extension URLSession: URLSessionProtocol {}
