//
// OpenRocky — Voice-first AI Agent
// https://github.com/openrocky
//
// Developed by everettjf with the assistance of Claude Code and Codex.
// Copyright (c) 2026 everettjf. All rights reserved.
//

import AVFoundation
import AVKit
import Combine
import Foundation
import UIKit

struct OpenRockyMediaItem: Identifiable, Sendable, Equatable {
    let id: String
    let url: URL
    let filename: String
    let kind: Kind

    enum Kind: String, Sendable { case audio, video }

    static let audioExtensions: Set<String> = ["mp3", "m4a", "aac", "wav", "flac", "ogg", "opus", "aiff"]
    static let videoExtensions: Set<String> = ["mp4", "mov", "m4v", "avi", "mkv", "webm"]

    init?(url: URL) {
        let ext = url.pathExtension.lowercased()
        guard !ext.isEmpty else { return nil }
        if Self.audioExtensions.contains(ext) {
            self.kind = .audio
        } else if Self.videoExtensions.contains(ext) {
            self.kind = .video
        } else {
            return nil
        }
        self.id = UUID().uuidString
        self.url = url
        self.filename = url.lastPathComponent
    }
}

enum OpenRockyMediaPlaybackMode: String, Sendable {
    case sequential
    case loop
    case random
    case repeatOne

    static func parse(_ raw: String) -> OpenRockyMediaPlaybackMode {
        switch raw.lowercased() {
        case "sequential", "seq", "order", "linear": .sequential
        case "loop", "repeat_all", "repeatall": .loop
        case "random", "shuffle", "shuffled": .random
        case "repeat_one", "repeatone", "loop_one", "loopone", "single": .repeatOne
        default: .sequential
        }
    }
}

/// Lightweight media player: scans a directory for audio/video and plays via AVPlayer.
///
/// Voice-first usage is the primary driver — the AI calls the `media` tool; the service owns
/// a single playlist + current index + mode. For video we hand off to an AVPlayerViewController
/// presented via `OpenRockyUIPresenterService`.
///
/// Note: this shares the iOS audio session with the voice bridges. When the voice session is
/// active and routing mic+speaker, starting music playback can fight for output. For M3 we can
/// add a "pause when voice active" coordinator; for now we let the user manage it.
@MainActor
final class OpenRockyMediaPlayerService: ObservableObject {
    static let shared = OpenRockyMediaPlayerService()

    @Published private(set) var playlist: [OpenRockyMediaItem] = []
    @Published private(set) var currentIndex: Int?
    @Published private(set) var mode: OpenRockyMediaPlaybackMode = .sequential
    @Published private(set) var isPlaying: Bool = false

    private var player: AVPlayer?
    private var endObserver: NSObjectProtocol?
    private var activeMountURL: URL?
    private var timeObserverToken: Any?
    /// Set when we paused the player to yield the audio session to a voice session.
    /// `resumeAfterVoice()` uses this to decide whether to resume automatically.
    private var pausedByVoice: Bool = false

    @Published private(set) var position: Double = 0
    @Published private(set) var duration: Double = 0

    private init() {}

    // MARK: - Loading

    /// Load media files from either an absolute file URL or a mount path in the form `mount:Name/sub/dir`.
    /// Returns the number of items loaded.
    @discardableResult
    func load(path: String, recursive: Bool = true) throws -> Int {
        let url = try resolvePath(path)
        let items = Self.scanDirectory(at: url, recursive: recursive)
        playlist = items
        currentIndex = items.isEmpty ? nil : 0
        rlog.info("Media loaded from \(url.lastPathComponent): \(items.count) items (recursive=\(recursive))", category: "Media")
        return items.count
    }

    private func resolvePath(_ path: String) throws -> URL {
        if path.hasPrefix("mount:") {
            let spec = String(path.dropFirst("mount:".count))
            // "mount:Name" or "mount:Name/sub/path"
            let parts = spec.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false).map(String.init)
            let name = parts[0]
            let subPath = parts.count > 1 ? parts[1] : ""
            guard let mount = OpenRockyMountStore.shared.mount(named: name) else {
                throw OpenRockyMediaPlayerError.mountNotFound(name)
            }
            guard let baseURL = mount.startAccessing() else {
                throw OpenRockyMediaPlayerError.mountInaccessible(name)
            }
            // Release previous mount scope, hold the new one for the playback duration.
            if let previous = activeMountURL, previous != baseURL {
                OpenRockyMount.stopAccessing(previous)
            }
            activeMountURL = baseURL
            return subPath.isEmpty ? baseURL : baseURL.appendingPathComponent(subPath)
        }
        let url = URL(fileURLWithPath: path)
        var isDir: ObjCBool = false
        guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else {
            throw OpenRockyMediaPlayerError.notADirectory(path)
        }
        return url
    }

    private static func scanDirectory(at url: URL, recursive: Bool) -> [OpenRockyMediaItem] {
        let fm = FileManager.default
        var collected: [OpenRockyMediaItem] = []
        let options: FileManager.DirectoryEnumerationOptions = recursive
            ? [.skipsHiddenFiles]
            : [.skipsHiddenFiles, .skipsSubdirectoryDescendants]
        guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey], options: options) else {
            return []
        }
        for case let fileURL as URL in enumerator {
            guard let item = OpenRockyMediaItem(url: fileURL) else { continue }
            collected.append(item)
        }
        return collected.sorted { $0.filename.localizedStandardCompare($1.filename) == .orderedAscending }
    }

    // MARK: - Playback

    func setMode(_ newMode: OpenRockyMediaPlaybackMode) {
        // Don't reshuffle the playlist on mode change — advance() handles random picking.
        // Leaving order intact lets the user flip between sequential and random without
        // losing the original file order.
        mode = newMode
    }

    func play() throws {
        guard let index = currentIndex, playlist.indices.contains(index) else {
            throw OpenRockyMediaPlayerError.emptyPlaylist
        }
        try startPlayback(at: index)
    }

    func pause() {
        player?.pause()
        isPlaying = false
    }

    func resume() {
        if player != nil {
            player?.play()
            isPlaying = true
        }
    }

    func stop() {
        teardownObservers()
        player?.pause()
        player = nil
        isPlaying = false
        position = 0
        duration = 0
    }

    func next() throws {
        try advance(direction: .forward)
    }

    func previous() throws {
        try advance(direction: .backward)
    }

    private enum Direction { case forward, backward }

    private func advance(direction: Direction) throws {
        guard !playlist.isEmpty else { throw OpenRockyMediaPlayerError.emptyPlaylist }
        var index = currentIndex ?? 0
        switch mode {
        case .random:
            if playlist.count > 1 {
                var next: Int
                repeat { next = Int.random(in: 0..<playlist.count) } while next == index
                index = next
            }
        case .repeatOne:
            // stay on current; just restart
            break
        case .loop:
            index = (index + (direction == .forward ? 1 : -1) + playlist.count) % playlist.count
        case .sequential:
            let candidate = index + (direction == .forward ? 1 : -1)
            if candidate < 0 || candidate >= playlist.count {
                // at the end (or start): stop instead of wrapping
                stop()
                currentIndex = direction == .forward ? playlist.count - 1 : 0
                return
            }
            index = candidate
        }
        currentIndex = index
        try startPlayback(at: index)
    }

    private func startPlayback(at index: Int) throws {
        guard let item = playlist[safe: index] else {
            throw OpenRockyMediaPlayerError.emptyPlaylist
        }

        teardownObservers()

        switch item.kind {
        case .audio:
            try configureAudioSessionForPlayback()
            let player = AVPlayer(url: item.url)
            self.player = player
            attachObservers(player: player)
            player.play()
            isPlaying = true
            rlog.info("Media play audio: \(item.filename)", category: "Media")

        case .video:
            // Video needs a view. Present AVPlayerViewController modally via the shared presenter.
            let player = AVPlayer(url: item.url)
            self.player = player
            attachObservers(player: player)
            presentVideoPlayer(player: player, title: item.filename)
            isPlaying = true
            rlog.info("Media play video: \(item.filename)", category: "Media")
        }
    }

    private func configureAudioSessionForPlayback() throws {
        // Keep mic available so voice can resume, but prioritize playback quality.
        let session = AVAudioSession.sharedInstance()
        try? session.setCategory(.playback, mode: .default, options: [.allowBluetooth, .allowAirPlay])
        try? session.setActive(true, options: [])
    }

    private func attachObservers(player: AVPlayer) {
        if let item = player.currentItem {
            endObserver = NotificationCenter.default.addObserver(
                forName: .AVPlayerItemDidPlayToEndTime,
                object: item,
                queue: .main
            ) { [weak self] _ in
                Task { @MainActor [weak self] in
                    self?.handleItemEnded()
                }
            }
        }
        let interval = CMTime(seconds: 0.5, preferredTimescale: 600)
        timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
            Task { @MainActor [weak self] in
                guard let self else { return }
                self.position = CMTimeGetSeconds(time)
                if let d = player.currentItem?.duration, d.isNumeric {
                    self.duration = CMTimeGetSeconds(d)
                }
            }
        }
    }

    private func teardownObservers() {
        if let token = endObserver {
            NotificationCenter.default.removeObserver(token)
            endObserver = nil
        }
        if let token = timeObserverToken {
            player?.removeTimeObserver(token)
            timeObserverToken = nil
        }
    }

    private func handleItemEnded() {
        switch mode {
        case .repeatOne:
            player?.seek(to: .zero)
            player?.play()
        case .sequential, .loop, .random:
            try? advance(direction: .forward)
        }
    }

    // MARK: - Video Presentation

    private func presentVideoPlayer(player: AVPlayer, title: String) {
        let vc = AVPlayerViewController()
        vc.player = player
        vc.modalPresentationStyle = .fullScreen
        vc.title = title

        let topVC = UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap(\.windows)
            .first(where: \.isKeyWindow)?
            .rootViewController?
            .topmost()
        topVC?.present(vc, animated: true)
    }

    // MARK: - Voice-session coordination

    /// Called when a voice session starts. Pauses music so it doesn't fight the mic / TTS.
    func pauseForVoice() {
        guard isPlaying else { return }
        rlog.info("Media: pausing for voice session", category: "Media")
        pause()
        pausedByVoice = true
    }

    /// Called when a voice session ends. Resumes music only if we paused it for voice.
    func resumeAfterVoice() {
        guard pausedByVoice else { return }
        rlog.info("Media: resuming after voice session", category: "Media")
        pausedByVoice = false
        resume()
    }

    // MARK: - Snapshot for tools

    func snapshot() -> [String: Any] {
        let current: [String: Any]? = currentIndex.flatMap { idx in
            guard let item = playlist[safe: idx] else { return nil }
            return [
                "id": item.id,
                "filename": item.filename,
                "kind": item.kind.rawValue,
                "index": idx
            ]
        }
        return [
            "playlist_count": playlist.count,
            "mode": mode.rawValue,
            "is_playing": isPlaying,
            "position_seconds": position,
            "duration_seconds": duration,
            "current": current as Any
        ]
    }
}

enum OpenRockyMediaPlayerError: LocalizedError {
    case emptyPlaylist
    case mountNotFound(String)
    case mountInaccessible(String)
    case notADirectory(String)

    var errorDescription: String? {
        switch self {
        case .emptyPlaylist: "No media loaded. Call media.load with a directory path first."
        case .mountNotFound(let name): "No mounted folder named '\(name)'. Mount it in Settings → External Folders first."
        case .mountInaccessible(let name): "Rocky could not access mount '\(name)' (bookmark may be stale)."
        case .notADirectory(let path): "Path is not a directory: \(path)"
        }
    }
}

private extension Array {
    subscript(safe index: Int) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}

private extension UIViewController {
    /// Walk the presentation chain to find the topmost view controller.
    func topmost() -> UIViewController {
        if let presented = presentedViewController {
            return presented.topmost()
        }
        if let nav = self as? UINavigationController, let top = nav.topViewController {
            return top.topmost()
        }
        if let tab = self as? UITabBarController, let selected = tab.selectedViewController {
            return selected.topmost()
        }
        return self
    }
}
