import AVFoundation
import IndieSync
import SwiftUI

/// Plays back a voice note's audio. Two sources: a fresh recording still on disk
/// (`localURL`, used by the composer) or a saved note whose bytes come back
/// through `SyncEngine.loadAudioData` (fail-fast, so an evicted file shows a
/// "downloading" retry state instead of stalling). `AVAudioPlayer` is not
/// Sendable, so the playback model stays main-actor confined.
struct AudioPlayerView: View {
    enum Source: Equatable {
        case localURL(URL)
        case note(id: ULID, duration: TimeInterval)
    }

    let source: Source

    @Environment(SyncEngine.self) private var syncEngine
    @State private var model = AudioPlaybackModel()

    var body: some View {
        Group {
            switch model.loadState {
            case .idle, .loading:
                HStack {
                    ProgressView().controlSize(.small)
                    Text("Loading audio…").foregroundStyle(.secondary)
                }
                .font(.callout)

            case .downloading:
                VStack(alignment: .leading, spacing: 6) {
                    Label("Downloading from iCloud…", systemImage: "icloud.and.arrow.down")
                        .foregroundStyle(.secondary)
                    Button("Retry") { Task { await load() } }
                        .buttonStyle(.bordered)
                        .controlSize(.small)
                }
                .font(.callout)

            case .failed:
                Label("Couldn't play audio", systemImage: "exclamationmark.triangle")
                    .foregroundStyle(.secondary)
                    .font(.callout)

            case .ready:
                player
            }
        }
        .task { await load() }
        .onDisappear { model.stop() }
    }

    private var player: some View {
        HStack(spacing: 12) {
            Button {
                model.togglePlay()
            } label: {
                Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill")
                    .font(.largeTitle)
                    .foregroundStyle(.tint)
            }
            .buttonStyle(.plain)

            VStack(spacing: 2) {
                Slider(
                    value: Binding(get: { model.progress }, set: { model.seek(toFraction: $0) }),
                    in: 0...1
                )
                HStack {
                    Text(Self.timeString(model.currentTime))
                    Spacer()
                    Text(Self.timeString(model.duration))
                }
                .font(.caption2.monospacedDigit())
                .foregroundStyle(.secondary)
            }
        }
    }

    private func load() async {
        switch source {
        case .localURL(let url):
            model.loadLocal(url)
        case .note(let id, let duration):
            await model.loadNote(id: id, fallbackDuration: duration, syncEngine: syncEngine)
        }
    }

    static func timeString(_ interval: TimeInterval) -> String {
        let total = Int(interval.rounded())
        return String(format: "%d:%02d", total / 60, total % 60)
    }
}

/// Owns the `AVAudioPlayer` and a lightweight progress ticker. Main-actor
/// confined because `AVAudioPlayer` isn't Sendable.
@Observable
@MainActor
final class AudioPlaybackModel {
    enum LoadState: Equatable { case idle, loading, ready, downloading, failed }

    private(set) var loadState: LoadState = .idle
    private(set) var isPlaying = false
    /// 0…1 position, bound to the scrubber.
    private(set) var progress: Double = 0
    private(set) var duration: TimeInterval = 0

    var currentTime: TimeInterval { duration * progress }

    private var player: AVAudioPlayer?
    private var ticker: Task<Void, Never>?

    func loadLocal(_ url: URL) {
        loadState = .loading
        do {
            let player = try AVAudioPlayer(contentsOf: url)
            player.prepareToPlay()
            adopt(player)
        } catch {
            loadState = .failed
        }
    }

    /// Fetch a saved note's audio (fail-fast). A miss means the file is evicted:
    /// show the downloading state and warm it up for the retry.
    func loadNote(id: ULID, fallbackDuration: TimeInterval, syncEngine: SyncEngine) async {
        loadState = .loading
        duration = fallbackDuration
        guard let data = await syncEngine.loadAudioData(for: id) else {
            loadState = .downloading
            await syncEngine.requestAudioDownload(for: id)
            return
        }
        do {
            let player = try AVAudioPlayer(data: data)
            player.prepareToPlay()
            adopt(player)
        } catch {
            loadState = .failed
        }
    }

    private func adopt(_ player: AVAudioPlayer) {
        self.player = player
        duration = player.duration
        progress = 0
        loadState = .ready
    }

    func togglePlay() {
        guard let player else { return }
        if player.isPlaying {
            player.pause()
            isPlaying = false
            ticker?.cancel()
        } else {
            activatePlaybackSession()
            player.play()
            isPlaying = true
            startTicker()
        }
    }

    func seek(toFraction fraction: Double) {
        guard let player else { return }
        progress = min(max(fraction, 0), 1)
        player.currentTime = progress * duration
    }

    func stop() {
        player?.stop()
        isPlaying = false
        ticker?.cancel()
        ticker = nil
        #if os(iOS)
        try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
        #endif
    }

    private func startTicker() {
        ticker?.cancel()
        ticker = Task { [weak self] in
            while !Task.isCancelled {
                guard let self, let player = self.player else { return }
                if player.isPlaying {
                    self.progress = self.duration > 0 ? player.currentTime / self.duration : 0
                } else {
                    // Reached the end (or was paused elsewhere): reset to the start.
                    self.isPlaying = false
                    self.progress = 0
                    return
                }
                try? await Task.sleep(for: .milliseconds(50))
            }
        }
    }

    private func activatePlaybackSession() {
        #if os(iOS)
        let session = AVAudioSession.sharedInstance()
        try? session.setCategory(.playback, mode: .spokenAudio)
        try? session.setActive(true)
        #endif
    }
}
