import AVFoundation
import Foundation
import SwiftUI

@MainActor
final class AppModel: ObservableObject {
    private let defaultServer = "http://mhsgroove.peterino.com:3001"
    private let userAgent = "BlastoiseiOSSketch/0.1"
    private let decoder = JSONDecoder()
    private let player = AVPlayer()

    private var cookieHeader: String {
        didSet { UserDefaults.standard.set(cookieHeader, forKey: "blastoise.cookie") }
    }
    private var webSocket: URLSessionWebSocketTask?
    private var receiveTask: Task<Void, Never>?
    private var reconnectTask: Task<Void, Never>?
    private var tickerTask: Task<Void, Never>?
    private var endObserver: NSObjectProtocol?
    private var reconnectAttempts = 0
    private var intentionalDisconnect = false
    private var lastLibraryEndTrackId: String?

    @Published var serverURL: String {
        didSet { UserDefaults.standard.set(serverURL, forKey: "blastoise.serverURL") }
    }
    @Published var authState: AuthState = .checking
    @Published var currentUser: UserSession?
    @Published var status = "Starting"
    @Published var channels: [ChannelInfo] = []
    @Published var libraryTracks: [Track] = []
    @Published var libraryLoaded = false
    @Published var myPlaylists: [Playlist] = []
    @Published var sharedPlaylists: [Playlist] = []
    @Published var playlistsLoaded = false
    @Published var selectedPlaylistId: String?
    @Published var selectedPlaylist: Playlist?
    @Published var sourceMode: SourceMode = .radio
    @Published var currentChannelId: String?
    @Published var currentTrackId: String?
    @Published var localLibraryIndex = -1
    @Published var listeners: [String] = []
    @Published var paused = true
    @Published var queue: [Track] = []
    @Published var queueLoaded = false
    @Published var currentIndex = 0
    @Published var playbackMode = "repeat-all"
    @Published var channelName = "No room"
    @Published var trackTitle = "No track"
    @Published var trackDuration: TimeInterval = 0
    @Published var serverTimestampMs: Int64 = 0
    @Published var stateMonotonicTime: TimeInterval = 0
    @Published var expectedPositionMs: Int64 = 0
    @Published var playerPositionMs: Int64 = 0
    @Published var driftMs: Int64 = 0
    @Published var playbackState = "idle"
    @Published var isPlaying = false
    @Published var debugEvents: [String] = []
    @Published var isUploading = false
    @Published var isFetching = false
    @Published var importStatus = ""
    @Published var pendingFetchPlaylist: FetchPlaylistResponse?

    var allPlaylists: [Playlist] { myPlaylists + sharedPlaylists }

    init() {
        serverURL = UserDefaults.standard.string(forKey: "blastoise.serverURL") ?? defaultServer
        cookieHeader = UserDefaults.standard.string(forKey: "blastoise.cookie") ?? ""
        configureAudioSession()
        startTicker()
        endObserver = NotificationCenter.default.addObserver(
            forName: .AVPlayerItemDidPlayToEndTime,
            object: nil,
            queue: .main
        ) { [weak self] _ in
            Task { @MainActor in
                self?.handlePlaybackEnded()
            }
        }
        Task { await validateSession() }
    }

    deinit {
        tickerTask?.cancel()
        receiveTask?.cancel()
        reconnectTask?.cancel()
        webSocket?.cancel(with: .goingAway, reason: nil)
        if let endObserver {
            NotificationCenter.default.removeObserver(endObserver)
        }
    }

    func validateSession() async {
        if cookieHeader.isEmpty {
            authState = .signedOut
            status = "Sign in required"
            return
        }

        authState = .checking
        status = "Checking session"
        do {
            let session = try await me()
            if let user = session, !user.isGuest {
                await becomeSignedIn(user)
            } else {
                clearSession()
                status = "Sign in required"
            }
        } catch {
            clearSession()
            status = "Session expired"
            addDebug("me failed: \(error.localizedDescription)")
        }
    }

    func signIn(username: String, password: String) async {
        guard !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
            status = "Username required"
            return
        }
        authState = .checking
        status = "Signing in"
        do {
            let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password]
            let envelope: AuthEnvelope = try await request("/api/auth/login", method: "POST", json: body)
            if let user = envelope.user {
                await becomeSignedIn(user)
            } else {
                status = "Login returned no user"
                authState = .signedOut
            }
        } catch {
            authState = .signedOut
            status = "Login failed"
            addDebug("login failed: \(error.localizedDescription)")
        }
    }

    func signUp(username: String, password: String) async {
        guard password.count >= 6 else {
            status = "Password must be 6+ chars"
            return
        }
        authState = .checking
        status = "Signing up"
        do {
            let body = ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password]
            let envelope: AuthEnvelope = try await request("/api/auth/signup", method: "POST", json: body)
            if let user = envelope.user {
                await becomeSignedIn(user)
            } else {
                status = "Signup returned no user"
                authState = .signedOut
            }
        } catch {
            authState = .signedOut
            status = "Signup failed"
            addDebug("signup failed: \(error.localizedDescription)")
        }
    }

    func logout() async {
        intentionalDisconnect = true
        closeSocket()
        player.pause()
        player.replaceCurrentItem(with: nil)
        _ = try? await requestRaw("/api/auth/logout", method: "POST", json: [:])
        clearSession()
        resetPlayback()
        status = "Signed out"
    }

    func connectToServer() async {
        if authState == .signedIn {
            status = "Reloading"
            await loadInitialData()
        } else {
            await validateSession()
        }
    }

    func loadLibraryIfNeeded() async {
        if !libraryLoaded {
            await loadLibrary()
        }
    }

    func loadPlaylistsIfNeeded() async {
        if !playlistsLoaded {
            await loadPlaylists()
        }
    }

    func loadLibrary() async {
        guard requireSignedIn("Sign in to browse library") else { return }
        do {
            libraryTracks = try await request("/api/library")
            libraryLoaded = true
            status = "Library loaded"
            addDebug("library \(libraryTracks.count)")
        } catch {
            status = "Library failed"
            addDebug("library failed: \(error.localizedDescription)")
        }
    }

    func loadPlaylists() async {
        guard requireSignedIn("Sign in to load playlists") else { return }
        do {
            let bundle: PlaylistBundle = try await request("/api/playlists")
            myPlaylists = bundle.mine
            sharedPlaylists = bundle.shared
            playlistsLoaded = true
            if selectedPlaylistId == nil {
                selectedPlaylistId = allPlaylists.first?.id
            }
            if let id = selectedPlaylistId {
                await loadPlaylist(id)
            }
            status = "Playlists loaded"
        } catch {
            status = "Playlists failed"
            addDebug("playlists failed: \(error.localizedDescription)")
        }
    }

    func loadPlaylist(_ id: String) async {
        guard requireSignedIn("Sign in to load playlists") else { return }
        selectedPlaylistId = id
        selectedPlaylist = nil
        do {
            selectedPlaylist = try await request("/api/playlists/\(encodePath(id))")
        } catch {
            status = "Playlist failed"
            addDebug("playlist failed: \(error.localizedDescription)")
        }
    }

    func uploadFiles(_ urls: [URL]) async {
        guard requireSignedIn("Sign in to upload") else { return }
        guard !urls.isEmpty else { return }

        isUploading = true
        importStatus = "Uploading \(urls.count) file(s)"
        var uploaded = 0
        var failed = 0

        for url in urls {
            do {
                try await uploadFile(url)
                uploaded += 1
                importStatus = "Uploaded \(uploaded)/\(urls.count)"
            } catch {
                failed += 1
                importStatus = "Upload failed: \(error.localizedDescription)"
                addDebug("upload failed: \(url.lastPathComponent)")
            }
        }

        isUploading = false
        importStatus = failed == 0 ? "Uploaded \(uploaded) file(s)" : "Uploaded \(uploaded), failed \(failed)"
        if uploaded > 0 {
            await loadLibrary()
        }
    }

    func fetchFromWebsite(_ urlString: String) async {
        guard requireSignedIn("Sign in to fetch URLs") else { return }
        let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else {
            importStatus = "Enter a URL"
            return
        }

        isFetching = true
        pendingFetchPlaylist = nil
        importStatus = "Checking URL"
        do {
            let response: FetchResponse = try await request("/api/fetch", method: "POST", json: ["url": trimmed])
            switch response {
            case .single(let item):
                importStatus = "Queued: \(item.title)"
                addDebug("fetch queued \(item.title.prefix(36))")
            case .playlist(let playlist):
                pendingFetchPlaylist = playlist
                importStatus = "Playlist found: \(playlist.title)"
            }
        } catch {
            importStatus = "Fetch failed"
            addDebug("fetch failed: \(error.localizedDescription)")
        }
        isFetching = false
    }

    func confirmFetchPlaylist() async {
        guard let playlist = pendingFetchPlaylist else { return }
        guard requireSignedIn("Sign in to fetch URLs") else { return }

        isFetching = true
        importStatus = "Queueing playlist"
        do {
            let body: [String: Any] = [
                "items": playlist.items.map { ["url": $0.url, "title": $0.title] },
                "playlistTitle": playlist.title
            ]
            let response: FetchConfirmResponse = try await request("/api/fetch/confirm", method: "POST", json: body)
            pendingFetchPlaylist = nil
            importStatus = "\(response.message) -> \(response.playlistName ?? "playlist")"
            await loadPlaylists()
        } catch {
            importStatus = "Playlist fetch failed"
            addDebug("fetch confirm failed: \(error.localizedDescription)")
        }
        isFetching = false
    }

    func cancelFetchPlaylist() {
        pendingFetchPlaylist = nil
        importStatus = "Playlist fetch cancelled"
    }

    func joinChannel(_ id: String) async {
        guard requireSignedIn("Sign in to join rooms") else { return }
        sourceMode = .radio
        intentionalDisconnect = false
        localLibraryIndex = -1
        currentChannelId = id
        status = "Joining"
        addDebug("join \(id)")
        connectWebSocket(channelId: id)
        await loadChannelState(id)
    }

    func playLibraryTrack(_ track: Track) {
        guard let index = libraryTracks.firstIndex(where: { $0.id == track.id }) else { return }
        sourceMode = .library
        intentionalDisconnect = true
        closeSocket()
        localLibraryIndex = index
        currentTrackId = track.id
        trackTitle = track.title
        trackDuration = track.duration
        channelName = "Library"
        paused = false
        status = "Local"
        serverTimestampMs = 0
        stateMonotonicTime = 0
        lastLibraryEndTrackId = nil
        replacePlayerItem(track: track, positionMs: 0)
        player.play()
        addDebug("local play \(track.title.prefix(36))")
        tick()
    }

    func togglePlay() {
        if sourceMode == .radio {
            sendSocket(["action": paused ? "unpause" : "pause"])
            status = paused ? "Unpause sent" : "Pause sent"
            return
        }

        if player.timeControlStatus == .playing {
            player.pause()
            paused = true
        } else {
            if localLibraryIndex < 0, let first = libraryTracks.first {
                playLibraryTrack(first)
                return
            }
            player.play()
            paused = false
        }
        tick()
    }

    func previous() {
        if sourceMode == .radio {
            jumpRadio(delta: -1)
        } else {
            guard !libraryTracks.isEmpty else { return }
            let nextIndex: Int
            if playbackMode == "shuffle", libraryTracks.count > 1 {
                nextIndex = randomLibraryIndex()
            } else if localLibraryIndex <= 0 {
                nextIndex = libraryTracks.count - 1
            } else {
                nextIndex = localLibraryIndex - 1
            }
            playLibraryTrack(libraryTracks[nextIndex])
        }
    }

    func next() {
        if sourceMode == .radio {
            jumpRadio(delta: 1)
        } else if !libraryTracks.isEmpty {
            playLibraryTrack(libraryTracks[nextLibraryIndex()])
        }
    }

    func seekBy(seconds: Int) {
        let durationMs = max(Int64(trackDuration * 1000), playerPositionMs)
        let target = max(Int64(0), min(durationMs, playerPositionMs + Int64(seconds * 1000)))
        seek(to: Double(target) / 1000)
    }

    func seek(to seconds: Double) {
        let target = max(0, seconds)
        if sourceMode == .radio {
            sendSocket(["action": "seek", "timestamp": target])
            status = "Seek sent"
        } else {
            player.seek(to: CMTime(seconds: target, preferredTimescale: 600))
        }
        tick()
    }

    func cyclePlaybackMode() async {
        let modes = ["once", "repeat-all", "repeat-one", "shuffle"]
        let current = modes.firstIndex(of: playbackMode) ?? 0
        let next = modes[(current + 1) % modes.count]
        playbackMode = next

        if sourceMode == .radio {
            guard let channelId = currentChannelId else {
                status = "No room"
                return
            }
            do {
                let _: ModeResponse = try await request(
                    "/api/channels/\(encodePath(channelId))/mode",
                    method: "POST",
                    json: ["mode": next]
                )
                status = "Mode \(modeLabel(next))"
            } catch {
                status = "Mode failed"
                addDebug("mode failed: \(error.localizedDescription)")
            }
        } else {
            status = "Mode \(modeLabel(next))"
        }
    }

    func queueCurrent(playNext: Bool) async {
        guard let trackId = currentTrackId else {
            status = "No track"
            return
        }
        await queueTrackIds([trackId], playNext: playNext)
    }

    func queueTrack(_ track: Track, playNext: Bool) async {
        await queueTrackIds([track.id], playNext: playNext)
    }

    func addPlaylistToQueue(_ playlist: Playlist, playNext: Bool) async {
        guard !playlist.trackIds.isEmpty else {
            status = "Empty playlist"
            return
        }
        await queueTrackIds(playlist.trackIds, playNext: playNext)
    }

    func jumpToQueueIndex(_ index: Int) {
        guard index >= 0, index < queue.count else { return }
        sendSocket(["action": "jump", "index": index])
        status = "Jump sent"
    }

    func removeQueueIndex(_ index: Int) async {
        guard let channelId = currentChannelId else {
            status = "No room"
            return
        }
        do {
            let _: QueueResponse = try await request(
                "/api/channels/\(encodePath(channelId))/queue",
                method: "PATCH",
                json: ["remove": [index]]
            )
            status = "Removed"
            await loadChannelState(channelId)
        } catch {
            status = "Remove failed"
            addDebug("remove failed: \(error.localizedDescription)")
        }
    }

    func stopAndExit() {
        intentionalDisconnect = true
        closeSocket()
        player.pause()
        player.replaceCurrentItem(with: nil)
        resetPlayback()
        status = "Stopped"
    }

    func track(for id: String) -> Track? {
        libraryTracks.first { $0.id == id } ?? queue.first { $0.id == id }
    }

    private func becomeSignedIn(_ user: UserSession) async {
        currentUser = user
        authState = user.isGuest ? .signedOut : .signedIn
        if user.isGuest {
            status = "Sign in required"
            return
        }
        status = "Signed in as \(user.username)"
        addDebug("auth \(user.username)")
        await loadInitialData()
    }

    private func loadInitialData() async {
        async let channelsLoad: Void = loadChannels()
        async let libraryLoad: Void = loadLibrary()
        async let playlistsLoad: Void = loadPlaylists()
        _ = await (channelsLoad, libraryLoad, playlistsLoad)
    }

    private func loadChannels() async {
        guard requireSignedIn("Sign in to load rooms") else { return }
        do {
            channels = try await request("/api/channels")
            addDebug("rooms \(channels.count)")
            let target = currentChannelId
                ?? channels.first(where: { $0.isDefault })?.id
                ?? channels.first?.id
            if let target, currentChannelId == nil {
                await joinChannel(target)
            }
        } catch {
            status = "Rooms failed"
            addDebug("rooms failed: \(error.localizedDescription)")
        }
    }

    private func loadChannelState(_ channelId: String) async {
        do {
            let state: ChannelState = try await request("/api/channels/\(encodePath(channelId))")
            handleChannelState(state)
        } catch {
            addDebug("room state failed: \(error.localizedDescription)")
        }
    }

    private func queueTrackIds(_ trackIds: [String], playNext: Bool) async {
        guard requireSignedIn("Sign in to change queue") else { return }
        guard let channelId = currentChannelId
            ?? channels.first(where: { $0.isDefault })?.id
            ?? channels.first?.id else {
            status = "No room"
            return
        }
        let insertAt = playNext ? max(0, currentIndex + 1) : nil
        var body: [String: Any] = ["add": trackIds]
        if let insertAt {
            body["insertAt"] = insertAt
        }
        do {
            let _: QueueResponse = try await request(
                "/api/channels/\(encodePath(channelId))/queue",
                method: "PATCH",
                json: body
            )
            status = playNext ? "Queued next" : "Queued"
            currentChannelId = channelId
            await loadChannelState(channelId)
            await loadChannels()
        } catch {
            status = "Queue failed"
            addDebug("queue failed: \(error.localizedDescription)")
        }
    }

    private func me() async throws -> UserSession? {
        let envelope: AuthEnvelope = try await request("/api/auth/me")
        return envelope.user
    }

    private func request<T: Decodable>(
        _ path: String,
        method: String = "GET",
        json: Any? = nil
    ) async throws -> T {
        let data = try await requestRaw(path, method: method, json: json)
        return try decoder.decode(T.self, from: data)
    }

    private func requestRaw(
        _ path: String,
        method: String = "GET",
        json: Any? = nil
    ) async throws -> Data {
        guard let url = URL(string: normalizedBaseURL() + path) else {
            throw APIError.invalidURL
        }
        var request = URLRequest(url: url)
        request.httpMethod = method
        request.timeoutInterval = 12
        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        if !cookieHeader.isEmpty {
            request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }
        if let json {
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONSerialization.data(withJSONObject: json)
        }

        let (data, response) = try await URLSession.shared.data(for: request)
        if let http = response as? HTTPURLResponse {
            captureCookie(http)
            guard (200..<300).contains(http.statusCode) else {
                let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
                throw APIError.http(http.statusCode, text)
            }
        }
        return data
    }

    private func uploadFile(_ fileURL: URL) async throws {
        guard let url = URL(string: normalizedBaseURL() + "/api/upload") else {
            throw APIError.invalidURL
        }

        let didAccess = fileURL.startAccessingSecurityScopedResource()
        defer {
            if didAccess {
                fileURL.stopAccessingSecurityScopedResource()
            }
        }

        let boundary = "BlastoiseBoundary-\(UUID().uuidString)"
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.timeoutInterval = 120
        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
        if !cookieHeader.isEmpty {
            request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }

        let bodyFile = try makeMultipartUploadFile(
            boundary: boundary,
            fieldName: "file",
            filename: fileURL.lastPathComponent,
            contentType: contentType(for: fileURL),
            fileURL: fileURL
        )
        defer { try? FileManager.default.removeItem(at: bodyFile) }

        let (data, response) = try await URLSession.shared.upload(for: request, fromFile: bodyFile)
        if let http = response as? HTTPURLResponse {
            captureCookie(http)
            guard (200..<300).contains(http.statusCode) else {
                let text = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
                throw APIError.http(http.statusCode, text)
            }
        }
    }

    private func makeMultipartUploadFile(
        boundary: String,
        fieldName: String,
        filename: String,
        contentType: String,
        fileURL: URL
    ) throws -> URL {
        let tempURL = FileManager.default.temporaryDirectory
            .appendingPathComponent("BlastoiseUpload-\(UUID().uuidString)")
            .appendingPathExtension("tmp")

        guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
            throw APIError.file("Could not create upload body")
        }

        do {
            let output = try FileHandle(forWritingTo: tempURL)
            defer { try? output.close() }

            let input = try FileHandle(forReadingFrom: fileURL)
            defer { try? input.close() }

            let header = "--\(boundary)\r\n"
                + "Content-Disposition: form-data; name=\"\(multipartEscaped(fieldName))\"; filename=\"\(multipartEscaped(filename))\"\r\n"
                + "Content-Type: \(contentType)\r\n\r\n"
            try output.write(contentsOf: Data(header.utf8))

            while let chunk = try input.read(upToCount: 1024 * 1024), !chunk.isEmpty {
                try output.write(contentsOf: chunk)
            }

            try output.write(contentsOf: Data("\r\n--\(boundary)--\r\n".utf8))
            return tempURL
        } catch {
            try? FileManager.default.removeItem(at: tempURL)
            throw error
        }
    }

    private func multipartEscaped(_ value: String) -> String {
        value
            .replacingOccurrences(of: "\\", with: "\\\\")
            .replacingOccurrences(of: "\"", with: "\\\"")
            .replacingOccurrences(of: "\r", with: " ")
            .replacingOccurrences(of: "\n", with: " ")
    }

    private func contentType(for url: URL) -> String {
        switch url.pathExtension.lowercased() {
        case "mp3": return "audio/mpeg"
        case "ogg", "opus": return "audio/ogg"
        case "flac": return "audio/flac"
        case "wav": return "audio/wav"
        case "m4a", "aac": return "audio/mp4"
        case "mp4": return "video/mp4"
        case "wma": return "audio/x-ms-wma"
        default: return "application/octet-stream"
        }
    }

    private func connectWebSocket(channelId: String) {
        closeSocket()
        guard let url = webSocketURL(channelId: channelId) else {
            status = "Bad WebSocket URL"
            return
        }
        var request = URLRequest(url: url)
        request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        if !cookieHeader.isEmpty {
            request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
        }

        let task = URLSession.shared.webSocketTask(with: request)
        webSocket = task
        task.resume()
        status = "Connected"
        addDebug("ws connect \(channelId)")
        reconnectAttempts = 0

        receiveTask = Task { [weak self, task] in
            while !Task.isCancelled {
                do {
                    let message = try await task.receive()
                    await MainActor.run {
                        self?.handleWebSocketMessage(message)
                    }
                } catch {
                    await MainActor.run {
                        self?.handleSocketError(error)
                    }
                    break
                }
            }
        }
    }

    private func closeSocket() {
        receiveTask?.cancel()
        receiveTask = nil
        reconnectTask?.cancel()
        reconnectTask = nil
        webSocket?.cancel(with: .goingAway, reason: nil)
        webSocket = nil
    }

    private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) {
        let data: Data
        switch message {
        case .string(let text):
            data = Data(text.utf8)
        case .data(let payload):
            data = payload
        @unknown default:
            addDebug("ws unknown message")
            return
        }

        guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            addDebug("ws bad json")
            return
        }

        if let type = object["type"] as? String, !type.isEmpty {
            handleTypedSocketMessage(type, object: object, data: data)
            return
        }

        do {
            let state = try decoder.decode(ChannelState.self, from: data)
            handleChannelState(state)
        } catch {
            addDebug("ws state decode failed")
        }
    }

    private func handleTypedSocketMessage(_ type: String, object: [String: Any], data: Data) {
        switch type {
        case "channel_list":
            if let channelData = try? JSONSerialization.data(withJSONObject: object["channels"] ?? []),
               let nextChannels = try? decoder.decode([ChannelInfo].self, from: channelData) {
                channels = nextChannels
            }
        case "switched":
            currentChannelId = object["channelId"] as? String
            status = "Tuned"
        case "kick":
            status = "Kicked"
            addDebug("kick \(object["reason"] as? String ?? "")")
        case "error":
            status = object["message"] as? String ?? "Server error"
            addDebug("ws error \(status)")
        default:
            addDebug("ws event \(type)")
        }
    }

    private func handleSocketError(_ error: Error) {
        guard !intentionalDisconnect, sourceMode == .radio else { return }
        status = "Disconnected"
        addDebug("ws fail: \(error.localizedDescription)")
        scheduleReconnect()
    }

    private func scheduleReconnect() {
        guard let channelId = currentChannelId else { return }
        reconnectTask?.cancel()
        let delaySeconds = min(3 * (reconnectAttempts + 1), 30)
        reconnectAttempts += 1
        addDebug("reconnect in \(delaySeconds)s")
        reconnectTask = Task { [weak self] in
            try? await Task.sleep(nanoseconds: UInt64(delaySeconds) * 1_000_000_000)
            await MainActor.run {
                guard let self, self.sourceMode == .radio, !self.intentionalDisconnect else { return }
                self.connectWebSocket(channelId: channelId)
            }
            if let self {
                await self.loadChannelState(channelId)
            }
        }
    }

    private func sendSocket(_ dictionary: [String: Any]) {
        guard let webSocket else {
            status = "Socket not connected"
            scheduleReconnect()
            return
        }
        guard let data = try? JSONSerialization.data(withJSONObject: dictionary),
              let payload = String(data: data, encoding: .utf8) else {
            return
        }
        webSocket.send(.string(payload)) { [weak self] error in
            if let error {
                Task { @MainActor in
                    self?.addDebug("ws send failed: \(error.localizedDescription)")
                }
            }
        }
    }

    private func handleChannelState(_ state: ChannelState) {
        guard sourceMode == .radio else { return }
        reconnectAttempts = 0
        currentChannelId = state.channelId.isEmpty ? currentChannelId : state.channelId
        channelName = state.channelName.isEmpty ? "Room" : state.channelName
        paused = state.paused
        playbackMode = state.playbackMode.isEmpty ? playbackMode : state.playbackMode
        currentIndex = state.currentIndex
        listeners = state.listeners
        if let stateQueue = state.queue {
            queue = stateQueue
            queueLoaded = true
        }
        serverTimestampMs = max(0, Int64(state.currentTimestamp * 1000))
        stateMonotonicTime = ProcessInfo.processInfo.systemUptime

        guard let track = state.track else {
            currentTrackId = nil
            trackTitle = "No track"
            trackDuration = 0
            player.pause()
            tick()
            return
        }

        trackTitle = track.title
        trackDuration = track.duration
        let targetMs = expectedPosition()

        if currentTrackId != track.id {
            currentTrackId = track.id
            replacePlayerItem(track: track, positionMs: targetMs)
            if state.paused {
                player.pause()
            } else {
                player.play()
            }
            tick()
            return
        }

        let drift = playerPosition() - targetMs
        if abs(drift) >= 2000 {
            seekPlayer(toMs: targetMs)
            addDebug("drift correction \(drift)ms")
        }

        if state.paused, player.timeControlStatus == .playing {
            player.pause()
        } else if !state.paused, player.timeControlStatus != .playing {
            player.play()
        }
        tick()
    }

    private func replacePlayerItem(track: Track, positionMs: Int64) {
        guard let url = trackURL(track.id) else {
            status = "Bad track URL"
            return
        }
        var headers = ["User-Agent": userAgent]
        if !cookieHeader.isEmpty {
            headers["Cookie"] = cookieHeader
        }
        let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
        let item = AVPlayerItem(asset: asset)
        player.replaceCurrentItem(with: item)
        seekPlayer(toMs: positionMs)
    }

    private func seekPlayer(toMs ms: Int64) {
        let seconds = Double(max(0, ms)) / 1000
        player.seek(
            to: CMTime(seconds: seconds, preferredTimescale: 600),
            toleranceBefore: .zero,
            toleranceAfter: .zero
        )
    }

    private func tick() {
        playerPositionMs = playerPosition()
        expectedPositionMs = expectedPosition()
        driftMs = sourceMode == .radio && currentTrackId != nil ? playerPositionMs - expectedPositionMs : 0
        isPlaying = player.timeControlStatus == .playing
        playbackState = playbackStateLabel()

        if sourceMode == .radio, currentTrackId != nil, !paused, abs(driftMs) >= 2000 {
            seekPlayer(toMs: expectedPositionMs)
            addDebug("tick drift correction \(driftMs)ms")
        }

        if sourceMode == .library {
            paused = !isPlaying
            if let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 {
                trackDuration = duration
            }
        }
    }

    private func expectedPosition() -> Int64 {
        guard sourceMode == .radio, stateMonotonicTime > 0 else {
            return playerPosition()
        }
        if paused {
            return serverTimestampMs
        }
        let elapsedMs = Int64((ProcessInfo.processInfo.systemUptime - stateMonotonicTime) * 1000)
        return serverTimestampMs + max(0, elapsedMs)
    }

    private func playerPosition() -> Int64 {
        let seconds = player.currentTime().seconds
        guard seconds.isFinite, seconds >= 0 else { return 0 }
        return Int64(seconds * 1000)
    }

    private func handlePlaybackEnded() {
        guard sourceMode == .library else { return }
        guard currentTrackId != lastLibraryEndTrackId else { return }
        lastLibraryEndTrackId = currentTrackId
        switch playbackMode {
        case "repeat-one":
            seekPlayer(toMs: 0)
            player.play()
            lastLibraryEndTrackId = nil
        case "repeat-all", "shuffle":
            if !libraryTracks.isEmpty {
                playLibraryTrack(libraryTracks[nextLibraryIndex()])
            }
        default:
            paused = true
            tick()
        }
    }

    private func jumpRadio(delta: Int) {
        guard !queue.isEmpty else { return }
        let target = (currentIndex + delta + queue.count) % queue.count
        sendSocket(["action": "jump", "index": target])
        status = "Jump sent"
    }

    private func nextLibraryIndex() -> Int {
        guard !libraryTracks.isEmpty else { return 0 }
        if playbackMode == "shuffle", libraryTracks.count > 1 {
            return randomLibraryIndex()
        }
        return localLibraryIndex < 0 ? 0 : (localLibraryIndex + 1) % libraryTracks.count
    }

    private func randomLibraryIndex() -> Int {
        guard libraryTracks.count > 1 else { return 0 }
        var next = Int.random(in: 0..<libraryTracks.count)
        while next == localLibraryIndex {
            next = Int.random(in: 0..<libraryTracks.count)
        }
        return next
    }

    private func resetPlayback() {
        sourceMode = .radio
        currentChannelId = nil
        currentTrackId = nil
        localLibraryIndex = -1
        listeners = []
        paused = true
        queue = []
        queueLoaded = false
        currentIndex = 0
        channelName = "No room"
        trackTitle = "No track"
        trackDuration = 0
        serverTimestampMs = 0
        stateMonotonicTime = 0
        tick()
    }

    private func clearSession() {
        cookieHeader = ""
        currentUser = nil
        authState = .signedOut
        channels = []
        myPlaylists = []
        sharedPlaylists = []
        selectedPlaylist = nil
        selectedPlaylistId = nil
        playlistsLoaded = false
    }

    private func requireSignedIn(_ message: String) -> Bool {
        if authState == .signedIn, currentUser?.isGuest == false { return true }
        status = message
        addDebug("auth required")
        return false
    }

    private func startTicker() {
        tickerTask = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(nanoseconds: 500_000_000)
                await MainActor.run {
                    self?.tick()
                }
            }
        }
    }

    private func configureAudioSession() {
        #if os(iOS)
        do {
            try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            addDebug("audio session failed")
        }
        #endif
    }

    private func playbackStateLabel() -> String {
        switch player.timeControlStatus {
        case .paused:
            return player.currentItem == nil ? "idle" : "paused"
        case .waitingToPlayAtSpecifiedRate:
            return "buffering"
        case .playing:
            return "playing"
        @unknown default:
            return "unknown"
        }
    }

    private func modeLabel(_ mode: String) -> String {
        switch mode {
        case "once": return "ONCE"
        case "repeat-all": return "LOOP ALL"
        case "repeat-one": return "LOOP ONE"
        case "shuffle": return "SHUFFLE"
        default: return mode.uppercased()
        }
    }

    private func captureCookie(_ response: HTTPURLResponse) {
        for (key, value) in response.allHeaderFields {
            guard String(describing: key).lowercased() == "set-cookie" else { continue }
            let raw = String(describing: value)
            if let cookie = raw
                .components(separatedBy: ";")
                .first(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix("blastoise_session=") }) {
                let clean = cookie.trimmingCharacters(in: .whitespaces)
                cookieHeader = clean == "blastoise_session=" ? "" : clean
            }
        }
    }

    private func normalizedBaseURL() -> String {
        var value = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
        if value.isEmpty {
            value = defaultServer
        }
        if !value.contains("://") {
            value = "http://\(value)"
        }
        return value.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
    }

    private func webSocketURL(channelId: String) -> URL? {
        guard var components = URLComponents(string: normalizedBaseURL()) else { return nil }
        components.scheme = components.scheme == "https" ? "wss" : "ws"
        components.path = "/api/channels/\(encodePath(channelId))/ws"
        return components.url
    }

    private func trackURL(_ trackId: String) -> URL? {
        URL(string: normalizedBaseURL() + "/api/tracks/\(encodePath(trackId))")
    }

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

    private func addDebug(_ message: String) {
        let stamp = Date().formatted(.dateTime.hour().minute().second())
        debugEvents.append("\(stamp) \(message)")
        while debugEvents.count > 12 {
            debugEvents.removeFirst()
        }
    }
}
