@preconcurrency import AVFoundation
import Capacitor
import CoreAudio
import Foundation
@preconcurrency import MediaPlayer

enum MyError: Error {
    case runtimeError(String)
}

private enum PlaybackStateValue: String {
    case playing
    case paused
    case stopped
}

// swiftlint:disable file_length
@objc(NativeAudio)
// swiftlint:disable:next type_body_length
public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
    private let pluginVersion: String = "8.4.30"
    public let identifier = "NativeAudio"
    public let jsName = "NativeAudio"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "setDebugMode", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "preload", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "playOnce", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "isPreloaded", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "play", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "pause", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "loop", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "unload", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "setVolume", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "setRate", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "isPlaying", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "getCurrentTime", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "getDuration", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "resume", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "setCurrentTime", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "clearCache", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "deinitPlugin", returnType: CAPPluginReturnPromise)
    ]
    private var logger = Logger(logTag: "NativeAudio")
    internal let audioQueue = DispatchQueue(label: "ee.forgr.audio.queue", qos: .userInitiated, attributes: .concurrent)
    /// A dictionary that stores audio asset objects by their asset IDs.
    ///
    /// - Important: Must only be accessed within `audioQueue.sync` blocks.
    internal var audioList: [String: Any] = [:] {
        didSet {
            // Ensure audioList modifications happen on audioQueue
            assert(DispatchQueue.getSpecific(key: queueKey) != nil)
        }
    }
    private let queueKey = DispatchSpecificKey<Bool>()
    /// Set while executing a block on the audio queue so getAudioAsset/endSession can avoid reentrant sync (deadlock).
    private let audioQueueContextKey = DispatchSpecificKey<Bool?>()
    var session = AVAudioSession.sharedInstance()

    // Track if audio session has been initialized
    private var audioSessionInitialized = false
    // Store the original audio category to restore on deinit
    private var originalAudioCategory: AVAudioSession.Category?
    private var originalAudioOptions: AVAudioSession.CategoryOptions?

    // Add observer for audio session interruptions
    private var interruptionObserver: Any?

    // Notification center support
    private var showNotification = false
    /// A mapping from asset IDs to their associated notification metadata for media playback.
    ///
    /// - Important: Must only be accessed within `audioQueue.sync` blocks.
    internal var notificationMetadataMap: [String: [String: String]] = [:]
    private var currentlyPlayingAssetId: String?

    /// Stores the asset IDs for playOnce operations to enable automatic cleanup after playback.
    ///
    /// - Important: Must only be accessed within `audioQueue.sync` blocks.
    internal var playOnceAssets: Set<String> = []

    private var pendingPlayTasks: [String: DispatchWorkItem] = [:]
    private var audioAssetData: [String: [String: Any]] = [:]
    var isRunningTests = false

    /// Initialize plugin state and audio-related handlers, and register background behavior for session management.
    ///
    /// Performs initial plugin setup after the plugin is loaded.
    ///
    /// Registers the plugin's audio queue, initializes default flags, defers full audio session activation until first use, and configures interruption handling and remote command controls. Also adds a background observer that will deactivate the audio session when the app enters background if no plugin-managed audio is playing and the system reports no other active audio.
    @objc override public func load() {
        super.load()
        audioQueue.setSpecific(key: queueKey, value: true)

        // Don't setup audio session on load - defer until first use
        // setupAudioSession()
        setupInterruptionHandling()
        setupRemoteCommandCenter()

        NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
            guard let strongSelf = self else { return }

            // When entering background, automatically deactivate audio session if not playing any audio
            strongSelf.audioQueue.sync {
                // Check if there are any playing assets
                let hasPlayingAssets = strongSelf.audioList.values.contains { asset in
                    if let audioAsset = asset as? AudioAsset {
                        return audioAsset.isPlaying()
                    }
                    return false
                }

                // Only deactivate if we have no playing assets AND no other audio is active
                // This prevents interfering with VoIP calls or other audio sessions
                if !hasPlayingAssets && !strongSelf.session.isOtherAudioPlaying && strongSelf.session.secondaryAudioShouldBeSilencedHint == false {
                    strongSelf.endSession()
                }
            }
        }
    }

    // Clean up on deinit
    deinit {
        if let observer = interruptionObserver {
            NotificationCenter.default.removeObserver(observer)
        }
    }

    private func setupAudioSession() {
        // Save the original audio session category before making changes
        if !audioSessionInitialized {
            originalAudioCategory = session.category
            originalAudioOptions = session.categoryOptions
            audioSessionInitialized = true
        }

        do {
            // Only set the category without immediately activating/deactivating
            try self.session.setCategory(AVAudioSession.Category.playback, options: .mixWithOthers)
            // Don't activate/deactivate in setup - we'll do this explicitly when needed
        } catch {
            print("Failed to setup audio session: \(error)")
        }
    }

    private func setupInterruptionHandling() {
        // Handle audio session interruptions
        interruptionObserver = NotificationCenter.default.addObserver(
            forName: AVAudioSession.interruptionNotification,
            object: nil,
            queue: nil) { [weak self] notification in
            guard let strongSelf = self else { return }

            guard let userInfo = notification.userInfo,
                  let typeInt = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
                  let type = AVAudioSession.InterruptionType(rawValue: typeInt) else {
                return
            }

            switch type {
            case .began:
                // Audio was interrupted - we could pause all playing audio here
                strongSelf.notifyListeners("interrupt", data: ["interrupted": true])
            case .ended:
                // Interruption ended - we could resume audio here if appropriate
                if let optionsInt = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt,
                   AVAudioSession.InterruptionOptions(rawValue: optionsInt).contains(.shouldResume) {
                    // Resume playback if appropriate (user wants to resume)
                    strongSelf.notifyListeners("interrupt", data: ["interrupted": false, "shouldResume": true])
                } else {
                    strongSelf.notifyListeners("interrupt", data: ["interrupted": false, "shouldResume": false])
                }
            @unknown default:
                break
            }
        }
    }

    private func resolvePlaybackState(assetId: String, audioAsset: AudioAsset?) -> PlaybackStateValue {
        if let audioAsset, audioAsset.isPlaying() {
            return .playing
        }
        if let data = audioAssetData[assetId],
           data["timeBeforePause"] != nil || data["volumeBeforePause"] != nil {
            return .paused
        }
        if currentlyPlayingAssetId == assetId {
            return .paused
        }
        return .stopped
    }

    private func notifyPlaybackState(assetId: String, reason: String, state: PlaybackStateValue? = nil, audioAsset: AudioAsset? = nil) {
        let emit = {
            let asset = audioAsset ?? self.audioList[assetId] as? AudioAsset
            let resolvedState = state ?? self.resolvePlaybackState(assetId: assetId, audioAsset: asset)
            var data: [String: Any] = [
                "assetId": assetId,
                "state": resolvedState.rawValue,
                "reason": reason,
                "isPlaying": resolvedState == .playing
            ]
            if let asset {
                data["currentTime"] = asset.getCurrentTime()
                let duration = asset.getDuration()
                if duration.isFinite {
                    data["duration"] = duration
                }
            }
            self.notifyListeners("playbackState", data: data)
        }

        if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
            emit()
        } else {
            audioQueue.async(execute: emit)
        }
    }

    internal func handlePlaybackCompletion(assetId: String, audioAsset: AudioAsset? = nil) {
        if currentlyPlayingAssetId == assetId {
            currentlyPlayingAssetId = nil
            clearNowPlayingInfo()
        }
        notifyPlaybackState(assetId: assetId, reason: "complete", state: .stopped, audioAsset: audioAsset)
    }

    /// Must be called on `audioQueue`. If `timeBeforePause` is stored, clears it and seeks (async for remote) before running `resume` + Now Playing refresh.
    /// Mirrors `resume(_:)` (non–fade-in path): restores `volumeBeforePause` via `setVolume`, clears that key from `audioAssetData`, then `resume()`.
    private func resumeAssetFromStoredPausePositionIfNeeded(assetId: String, asset: AudioAsset, reason: String = "resume") {
        var restoredTime: TimeInterval?
        if var data = audioAssetData[assetId],
           let time = data["timeBeforePause"] as? TimeInterval {
            restoredTime = time
            data.removeValue(forKey: "timeBeforePause")
            audioAssetData[assetId] = data
        }

        var restoredVolume: Float?
        if let data = audioAssetData[assetId], let volume = data["volumeBeforePause"] as? Float {
            restoredVolume = volume
        }

        let resumeAndRefreshNowPlaying: () -> Void = { [weak self] in
            guard let self else { return }
            if let volume = restoredVolume {
                asset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
            }
            if var data = self.audioAssetData[assetId] {
                data.removeValue(forKey: "volumeBeforePause")
                self.audioAssetData[assetId] = data
            }
            asset.resume()
            if self.showNotification {
                self.currentlyPlayingAssetId = assetId
                self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
            }
            self.notifyPlaybackState(assetId: assetId, reason: reason, state: .playing, audioAsset: asset)
        }
        if let resumeTime = restoredTime {
            asset.setCurrentTime(time: resumeTime) { [weak self] in
                guard let self else { return }
                audioQueue.async(flags: .barrier, execute: resumeAndRefreshNowPlaying)
            }
        } else {
            resumeAndRefreshNowPlaying()
        }
    }

    // swiftlint:disable function_body_length
    private func setupRemoteCommandCenter() {
        let commandCenter = MPRemoteCommandCenter.shared()

        // Play command
        commandCenter.playCommand.addTarget { [weak self] _ in
            guard let self = self, let assetId = self.currentlyPlayingAssetId else {
                return .noSuchContent
            }

            self.audioQueue.sync {
                guard let asset = self.audioList[assetId] as? AudioAsset else {
                    return
                }

                if !asset.isPlaying() {
                    self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset, reason: "remotePlay")
                }
            }
            return .success
        }

        // Pause command
        commandCenter.pauseCommand.addTarget { [weak self] _ in
            guard let self = self, let assetId = self.currentlyPlayingAssetId else {
                return .noSuchContent
            }

            self.audioQueue.sync {
                guard let asset = self.audioList[assetId] as? AudioAsset else {
                    return
                }

                // Persist the paused position for the next resume.
                let timeBeforePause = asset.getCurrentTime()
                var data = self.audioAssetData[assetId] ?? [:]
                data["timeBeforePause"] = timeBeforePause
                self.audioAssetData[assetId] = data

                asset.pause()
                self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
                self.notifyPlaybackState(assetId: assetId, reason: "remotePause", state: .paused, audioAsset: asset)
            }
            return .success
        }

        // Stop command
        commandCenter.stopCommand.addTarget { [weak self] _ in
            guard let self = self, let assetId = self.currentlyPlayingAssetId else {
                return .noSuchContent
            }

            self.audioQueue.sync {
                guard let asset = self.audioList[assetId] as? AudioAsset else {
                    return
                }

                // Sample before `stop()` — `AudioAsset.stop()` resets every channel's `currentTime` to 0.
                let elapsedTime = asset.getCurrentTime()
                let duration = asset.getDuration()
                asset.stop()
                // Keep `currentlyPlayingAssetId` and Now Playing metadata so the lock screen card
                // stays until `unload()` (or natural completion / another `play()` replaces it).
                if self.showNotification,
                   self.currentlyPlayingAssetId == assetId {
                    self.updatePlaybackState(
                        isPlaying: false,
                        elapsedTime: elapsedTime,
                        duration: duration
                    )
                }
                self.notifyPlaybackState(assetId: assetId, reason: "remoteStop", state: .stopped, audioAsset: asset)
            }
            return .success
        }

        // Toggle play/pause command
        commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
            guard let self = self, let assetId = self.currentlyPlayingAssetId else {
                return .noSuchContent
            }

            self.audioQueue.sync {
                guard let asset = self.audioList[assetId] as? AudioAsset else {
                    return
                }

                if asset.isPlaying() {
                    // Persist the paused position for the next resume.
                    let timeBeforePause = asset.getCurrentTime()
                    var data = self.audioAssetData[assetId] ?? [:]
                    data["timeBeforePause"] = timeBeforePause
                    self.audioAssetData[assetId] = data

                    asset.pause()
                    self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
                    self.notifyPlaybackState(assetId: assetId, reason: "remotePause", state: .paused, audioAsset: asset)
                } else {
                    self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset, reason: "remotePlay")
                }
            }
            return .success
        }

        // Skip forward command
        commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: 15)]
        commandCenter.skipForwardCommand.isEnabled = true
        commandCenter.skipForwardCommand.addTarget { [weak self] event in
            guard let self else { return .commandFailed }
            guard let skipEvent = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
            return self.handleSeekCommand(delta: skipEvent.interval)
        }

        // Skip backward command
        commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: 15)]
        commandCenter.skipBackwardCommand.isEnabled = true
        commandCenter.skipBackwardCommand.addTarget { [weak self] event in
            guard let self else { return .commandFailed }
            guard let skipEvent = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
            return self.handleSeekCommand(delta: -skipEvent.interval)
        }

        // Scrub / change position command
        commandCenter.changePlaybackPositionCommand.isEnabled = true
        commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
            guard let self else { return .commandFailed }
            guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
            return self.handleSeekCommand(targetTime: positionEvent.positionTime)
        }
    }
    // swiftlint:enable function_body_length

    private func handleSeekCommand(delta: TimeInterval? = nil, targetTime: TimeInterval? = nil) -> MPRemoteCommandHandlerStatus {
        guard let assetId = currentlyPlayingAssetId else {
            return .noSuchContent
        }

        var asset: AudioAsset?
        audioQueue.sync {
            asset = audioList[assetId] as? AudioAsset
        }

        guard let audioAsset = asset else {
            return .noSuchContent
        }

        let duration = audioAsset.getDuration()
        let currentTime = audioAsset.getCurrentTime()
        let requestedTime: TimeInterval

        if let delta {
            requestedTime = currentTime + delta
        } else if let targetTime {
            requestedTime = targetTime
        } else {
            return .commandFailed
        }

        let clampedTime: TimeInterval
        if duration.isFinite && duration > 0 {
            clampedTime = min(max(requestedTime, 0), duration)
        } else {
            clampedTime = max(requestedTime, 0)
        }

        audioAsset.setCurrentTime(time: clampedTime) { [weak self, weak audioAsset] in
            guard let self else { return }
            let isPlaying = audioAsset?.isPlaying() ?? false
            let durationValue = duration.isFinite && duration > 0 ? duration : nil

            if self.showNotification,
               self.currentlyPlayingAssetId == assetId {
                self.updatePlaybackState(
                    isPlaying: isPlaying,
                    elapsedTime: clampedTime,
                    duration: durationValue
                )
            }

            // Emit a currentTime event so JS can sync UI immediately after remote seek.
            let roundedTime = round(clampedTime * 10) / 10
            self.notifyListeners("currentTime", data: [
                "currentTime": roundedTime,
                "assetId": assetId
            ])
        }

        return .success
    }

    @objc func setDebugMode(_ call: CAPPluginCall) {
        let debug = call.getBool("enabled") ?? false
        Logger.debugModeEnabled = debug
        if debug {
            logger.info("Debug mode enabled")
        }
        call.resolve()
    }

    @objc func configure(_ call: CAPPluginCall) {
        // Save original category on first configure call
        if !audioSessionInitialized {
            originalAudioCategory = session.category
            originalAudioOptions = session.categoryOptions
            audioSessionInitialized = true
        }

        let focus = call.getBool(Constant.FocusAudio) ?? false
        let background = call.getBool(Constant.Background) ?? false
        let ignoreSilent = call.getBool(Constant.IgnoreSilent) ?? true
        // Only update showNotification when explicitly provided so repeated configure() calls
        // (e.g. when switching assets) don't reset it to false and break Now Playing for the next play
        if let showNotification = call.getBool(Constant.ShowNotification) {
            self.showNotification = showNotification
        }

        logger.info("Configuring audio session with focus=%@ background=%@ ignoreSilent=%@", "\(focus)", "\(background)", "\(ignoreSilent)")

        // Use a single audio session configuration block for better atomicity
        do {
            // Set category first
            // Fix for issue #202: When showNotification is enabled, use .playback without
            // .mixWithOthers or .duckOthers to allow Now Playing info to display in
            // Control Center and lock screen.
            //
            // IMPORTANT: This is a behavior trade-off:
            // - With .playback + .default mode: Now Playing info shows, but interrupts other audio
            // - With .mixWithOthers or .duckOthers: Audio mixes, but no Now Playing info
            //
            // This is required because iOS only shows Now Playing controls for audio sessions
            // that use the .playback category without mixing options. This means the app becomes
            // the primary audio source and will interrupt background music from other apps.
            if self.showNotification {
                // Use playback category with default mode for notification support
                try self.session.setCategory(AVAudioSession.Category.playback, mode: .default)
            } else if focus {
                try self.session.setCategory(AVAudioSession.Category.playback, options: .duckOthers)
            } else if !ignoreSilent {
                try self.session.setCategory(AVAudioSession.Category.ambient, options: focus ? .duckOthers : .mixWithOthers)
            } else {
                try self.session.setCategory(AVAudioSession.Category.playback, options: .mixWithOthers)
            }

            // Only activate if needed (background mode)
            if background {
                try self.session.setActive(true)
            }

        } catch {
            logger.error("Failed to configure audio session: %@", error.localizedDescription)
        }

        call.resolve()
    }

    /// Checks whether an audio asset with the given assetId is currently loaded.
    /// - Parameter call: A CAPPluginCall that must include the `"assetId"` string identifying the audio asset to check. The call is rejected with `"Missing assetId"` if the parameter is absent.
    /// - Returns: A dictionary with key `found` set to `true` if the asset is loaded, `false` otherwise.
    @objc func isPreloaded(_ call: CAPPluginCall) {
        guard let assetId = call.getString(Constant.AssetIdKey) else {
            call.reject("Missing assetId")
            return
        }

        audioQueue.sync {
            call.resolve([
                "found": self.audioList[assetId] != nil
            ])
        }
    }

    /// Preloads an audio asset into the plugin's audio cache for full-featured playback.
    ///
    /// The call should include the asset configuration (for example `assetId`, `assetPath`) and may include optional playback and metadata options such as `channels`, `volume`, `delay`, `isUrl`, `headers`, and notification metadata. The plugin will load the asset so it is ready for subsequent play, loop, stop and other playback operations.
    /// - Parameters:
    /// Preloads an audio asset with advanced playback options for later use.
    ///
    /// Prepares the asset specified in the plugin call (local file, bundled resource, or remote URL) using options such as `assetId`, `assetPath`, `isUrl`, `volume`, `channels`, `delay`, headers, and notification metadata so it is ready for playback.
    /// - Parameter call: The CAPPluginCall containing preload options and identifiers.
    @objc func preload(_ call: CAPPluginCall) {
        preloadAsset(call, isComplex: true)
    }

    // swiftlint:disable:next cyclomatic_complexity function_body_length
    @objc func playOnce(_ call: CAPPluginCall) {
        // Generate unique temporary asset ID
        let assetId = "playOnce_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(8))"

        // Extract options
        let assetPath = call.getString(Constant.AssetPathKey) ?? ""
        let autoPlay = call.getBool("autoPlay") ?? true
        let deleteAfterPlay = call.getBool("deleteAfterPlay") ?? false
        let volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
        let isLocalUrl = call.getBool("isUrl") ?? false

        if assetPath == "" {
            call.reject(Constant.ErrorAssetPath)
            return
        }

        // Parse notification metadata if provided (on main thread)
        var metadataDict: [String: String]?
        if let metadata = call.getObject(Constant.NotificationMetadata) {
            var tempDict: [String: String] = [:]
            if let title = metadata["title"] as? String {
                tempDict["title"] = title
            }
            if let artist = metadata["artist"] as? String {
                tempDict["artist"] = artist
            }
            if let album = metadata["album"] as? String {
                tempDict["album"] = album
            }
            if let artworkUrl = metadata["artworkUrl"] as? String {
                tempDict["artworkUrl"] = artworkUrl
            }
            if !tempDict.isEmpty {
                metadataDict = tempDict
            }
        }

        // Ensure audio session is initialized
        if !audioSessionInitialized {
            setupAudioSession()
        }

        // Track this as a playOnce asset and store metadata (thread-safe)
        audioQueue.sync(flags: .barrier) {
            self.playOnceAssets.insert(assetId)
            if let metadata = metadataDict {
                self.notificationMetadataMap[assetId] = metadata
            }
        }

        // Create a completion handler for cleanup
        let cleanupHandler: () -> Void = { [weak self] in
            guard let self = self else { return }

            self.audioQueue.async(flags: .barrier) {
                guard let asset = self.audioList[assetId] as? AudioAsset else { return }

                // Get the file path before unloading if we need to delete
                // Only delete if it's a local file:// URL, not remote streaming URLs
                var filePathToDelete: String?
                if deleteAfterPlay {
                    if let url = asset.channels.first?.url, url.isFileURL {
                        filePathToDelete = url.path
                    }
                }

                // Unload the asset
                asset.unload()
                self.audioList[assetId] = nil
                self.playOnceAssets.remove(assetId)
                self.notificationMetadataMap.removeValue(forKey: assetId)

                // Reset current track if this was the currently playing asset (next play will overwrite Now Playing)
                if self.currentlyPlayingAssetId == assetId {
                    self.currentlyPlayingAssetId = nil
                }

                // Delete file if requested and it's a local file
                if let filePath = filePathToDelete {
                    let fileManager = FileManager.default
                    let resolvedPath: String
                    if filePath.hasPrefix("file://") {
                        resolvedPath = URL(string: filePath)?.path ?? filePath
                    } else {
                        resolvedPath = filePath
                    }

                    do {
                        if fileManager.fileExists(atPath: resolvedPath) {
                            try fileManager.removeItem(atPath: resolvedPath)
                            print("Deleted file after playOnce: \(resolvedPath)")
                        }
                    } catch {
                        print("Error deleting file after playOnce: \(error.localizedDescription)")
                    }
                }
            }
        }

        /// Cleans up tracking data when playOnce fails to prevent memory leaks.
        ///
        /// Removes the asset ID from both playOnceAssets set and notificationMetadataMap
        /// to ensure proper cleanup when an error occurs during playOnce execution.
        ///
        /// Removes transient tracking for a one-off playback asset and its associated notification metadata.
        /// Remove tracking and Now Playing metadata for a play-once asset after a failed load or playback.
        /// - Parameter assetId: The asset identifier to remove from play-once tracking and notification metadata.
        func cleanupOnFailure(assetId: String) {
            self.playOnceAssets.remove(assetId)
            self.notificationMetadataMap.removeValue(forKey: assetId)
        }

        // Inline preload logic directly (avoid creating mock PluginCall)
        audioQueue.async(flags: .barrier) { [weak self] in
            guard let self = self else { return }

            // Check if asset already exists
            if self.audioList[assetId] != nil {
                cleanupOnFailure(assetId: assetId)
                call.reject(Constant.ErrorAssetAlreadyLoaded + " - " + assetId)
                return
            }

            var basePath: String?

            if let url = URL(string: assetPath), url.scheme != nil {
                // Check if it's a local file URL or a remote URL
                if url.isFileURL {
                    // Handle local file URL
                    basePath = url.path

                    if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
                        let audioAsset = AudioAsset(
                            owner: self,
                            withAssetId: assetId,
                            withPath: basePath,
                            withChannels: 1,
                            withVolume: volume
                        )
                        self.audioList[assetId] = audioAsset
                    } else {
                        cleanupOnFailure(assetId: assetId)
                        call.reject(Constant.ErrorAssetPath + " - " + assetPath)
                        return
                    }
                } else {
                    // Handle remote URL
                    var headers: [String: String]?
                    if let headersObj = call.getObject("headers") {
                        headers = [:]
                        for (key, value) in headersObj {
                            if let stringValue = value as? String {
                                headers?[key] = stringValue
                            }
                        }
                    }
                    let remoteAudioAsset = RemoteAudioAsset(
                        owner: self,
                        withAssetId: assetId,
                        withPath: assetPath,
                        withChannels: 1,
                        withVolume: volume,
                        withHeaders: headers
                    )
                    self.audioList[assetId] = remoteAudioAsset
                }
            } else if !isLocalUrl {
                // Handle public folder
                let publicAssetPath = assetPath.starts(with: "public/") ? assetPath : "public/" + assetPath
                let assetPathSplit = publicAssetPath.components(separatedBy: ".")
                if assetPathSplit.count >= 2 {
                    basePath = Bundle.main.path(forResource: assetPathSplit[0], ofType: assetPathSplit[1])
                } else {
                    cleanupOnFailure(assetId: assetId)
                    call.reject("Invalid asset path format: \(assetPath)")
                    return
                }

                if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
                    let audioAsset = AudioAsset(
                        owner: self,
                        withAssetId: assetId,
                        withPath: basePath,
                        withChannels: 1,
                        withVolume: volume
                    )
                    self.audioList[assetId] = audioAsset
                } else {
                    cleanupOnFailure(assetId: assetId)
                    call.reject(Constant.ErrorAssetPath + " - " + assetPath)
                    return
                }
            } else {
                // Handle local file path
                let fileURL = URL(fileURLWithPath: assetPath)
                basePath = fileURL.path

                if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
                    let audioAsset = AudioAsset(
                        owner: self,
                        withAssetId: assetId,
                        withPath: basePath,
                        withChannels: 1,
                        withVolume: volume
                    )
                    self.audioList[assetId] = audioAsset
                } else {
                    cleanupOnFailure(assetId: assetId)
                    call.reject(Constant.ErrorAssetPath + " - " + assetPath)
                    return
                }
            }

            // Get the loaded asset
            guard let asset = self.audioList[assetId] as? AudioAsset else {
                // Cleanup on failure
                cleanupOnFailure(assetId: assetId)
                call.reject("Failed to load asset for playOnce")
                return
            }

            // Set up completion handler
            asset.onComplete = {
                cleanupHandler()
            }

            // Auto-play if requested
            if autoPlay {
                self.activateSession()
                asset.play(time: 0, volume: nil)

                // Update notification center if enabled
                if self.showNotification {
                    self.currentlyPlayingAssetId = assetId
                    self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
                    self.updatePlaybackState(isPlaying: true)
                }
                self.notifyPlaybackState(assetId: assetId, reason: "playOnce", state: .playing, audioAsset: asset)
            }

            // Return the generated assetId
            call.resolve(["assetId": assetId])
        }
    }

    /// Activates the app's audio session when no other audio is playing.
    /// Activate the shared AVAudioSession when no other audio is playing.
    ///
    /// If the system reports other audio is playing, the session is left inactive. On failure to activate, the error is printed to the console.
    func activateSession() {
        do {
            // Only activate if not already active
            if !session.isOtherAudioPlaying {
                try self.session.setActive(true)
            }
        } catch {
            print("Failed to set session active: \(error)")
        }
    }

    func endSession() {
        do {
            // Avoid reentrant sync when already on audio queue (e.g. from pause(), didEnterBackground) to prevent deadlock
            let hasPlayingAssets: Bool
            if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
                hasPlayingAssets = self.audioList.values.contains { asset in
                    if let audioAsset = asset as? AudioAsset {
                        return audioAsset.isPlaying()
                    }
                    return false
                }
            } else {
                hasPlayingAssets = audioQueue.sync {
                    return self.audioList.values.contains { asset in
                        if let audioAsset = asset as? AudioAsset {
                            return audioAsset.isPlaying()
                        }
                        return false
                    }
                }
            }

            // Only deactivate if no assets are playing AND no other audio is active,
            // and only when we're not in a record-capable mode (e.g. usage with CameraPreview plugin).
            let isRecordCapableCategory: Bool = {
                switch session.category {
                case .record, .playAndRecord, .multiRoute:
                    return true
                default:
                    return false
                }
            }()

            if !hasPlayingAssets &&
                !session.isOtherAudioPlaying &&
                session.secondaryAudioShouldBeSilencedHint == false &&
                !isRecordCapableCategory {
                try self.session.setActive(false, options: .notifyOthersOnDeactivation)
            }
        } catch {
            print("Failed to deactivate audio session: \(error)")
        }
    }

    public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        // Don't immediately end the session here, as other players might still be active
        // Instead, check if all players are done and clear Now Playing if this asset was current
        audioQueue.async { [weak self] in
            guard let self = self else { return }
            var completedAssetId: String?

            // Find which asset this player belongs to; if it was the currently playing one, clear notification
            for (audioId, asset) in self.audioList {
                if let audioAsset = asset as? AudioAsset, audioAsset.channels.contains(player) {
                    completedAssetId = audioId
                    self.handlePlaybackCompletion(assetId: audioId, audioAsset: audioAsset)
                    break
                }
            }

            // Avoid recursive calls by checking if the asset is still in the list
            let hasPlayingAssets = self.audioList.values.contains { asset in
                if let audioAsset = asset as? AudioAsset {
                    // Check if the asset has any playing channels other than the one that just finished
                    return audioAsset.channels.contains { $0 != player && $0.isPlaying }
                }
                return false
            }

            // Only end the session if no more assets are playing
            if !hasPlayingAssets {
                // If we didn't find the asset above (e.g. playOnce already removed it), clear notification when nothing is playing
                if completedAssetId == nil, self.currentlyPlayingAssetId != nil {
                    self.currentlyPlayingAssetId = nil
                    self.clearNowPlayingInfo()
                }
                self.endSession()
            }
        }
    }

    // swiftlint:disable:next function_body_length
    @objc func play(_ call: CAPPluginCall) {
        let audioId = call.getString(Constant.AssetIdKey) ?? ""
        let time = max(call.getDouble(Constant.Time) ?? 0, 0)
        let delay = max(call.getDouble(Constant.Delay) ?? 0, 0)
        let volume = call.getFloat(Constant.Volume)
        let fadeIn = call.getBool(Constant.FadeIn) ?? false
        let fadeOut = call.getBool(Constant.FadeOut) ?? false
        let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
        let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
        let fadeOutStartTime = call.getDouble(Constant.FadeOutStartTime) ?? 0.0

        // Ensure audio session is initialized before first play
        if !audioSessionInitialized {
            setupAudioSession()
        }

        // Use sync for operations that need to be blocking
        audioQueue.sync {
            guard !audioList.isEmpty else {
                call.reject("Audio list is empty")
                return
            }

            guard let asset = audioList[audioId] else {
                call.reject(Constant.ErrorAssetNotFound)
                return
            }

            if let audioAsset = asset as? AudioAsset {
                self.activateSession()
                cancelPendingPlay(for: audioId)
                clearAudioAssetData(for: audioId)

                let playBlock = { [weak self] in
                    guard let self else { return }
                    self.executeOnAudioQueue {
                        if fadeIn {
                            audioAsset.playWithFade(time: time, volume: volume, fadeInDuration: fadeInDuration)
                        } else {
                            audioAsset.play(time: time, volume: volume)
                        }
                        self.pendingPlayTasks[audioId] = nil

                        if fadeOut {
                            self.handleFadeOut(
                                for: audioAsset,
                                audioId: audioId,
                                fadeOutDuration: fadeOutDuration,
                                fadeOutStartTime: fadeOutStartTime
                            )
                        }

                        if self.showNotification {
                            self.currentlyPlayingAssetId = audioId
                            self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
                            self.updatePlaybackState(isPlaying: true)
                        }
                        self.notifyPlaybackState(assetId: audioId, reason: "play", state: .playing, audioAsset: audioAsset)
                        call.resolve()
                    }
                }

                if delay > 0 {
                    let workItem = DispatchWorkItem(block: playBlock)
                    pendingPlayTasks[audioId] = workItem
                    DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
                } else {
                    playBlock()
                }
            } else if let audioNumber = asset as? NSNumber {
                self.activateSession()
                AudioServicesPlaySystemSound(SystemSoundID(audioNumber.intValue))
                call.resolve()
            } else {
                call.reject(Constant.ErrorAssetNotFound)
            }
        }
    }

    @objc private func getAudioAsset(_ call: CAPPluginCall) -> AudioAsset? {
        // Avoid reentrant sync when already on audio queue (e.g. from pause()) to prevent deadlock
        if DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
            return self.audioList[call.getString(Constant.AssetIdKey) ?? ""] as? AudioAsset
        }
        var asset: AudioAsset?
        audioQueue.sync {
            asset = self.audioList[call.getString(Constant.AssetIdKey) ?? ""] as? AudioAsset
        }
        return asset
    }

    @objc func setCurrentTime(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            cancelPendingPlay(for: audioAsset.assetId)
            clearAudioAssetData(for: audioAsset.assetId)
            let time = max(call.getDouble(Constant.Time) ?? 0, 0)
            audioAsset.setCurrentTime(time: time) {
                call.resolve()
            }
        }
    }

    @objc func getDuration(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            call.resolve([
                "duration": audioAsset.getDuration()
            ])
        }
    }

    @objc func getCurrentTime(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            call.resolve([
                "currentTime": audioAsset.getCurrentTime()
            ])
        }
    }

    // swiftlint:disable:next function_body_length
    @objc func resume(_ call: CAPPluginCall) {
        let audioId = call.getString(Constant.AssetIdKey) ?? ""
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }
            self.activateSession()
            let fadeIn = call.getBool(Constant.FadeIn) ?? false
            let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
            var restoredVolume: Float?

            var restoredTime: TimeInterval?
            if var data = audioAssetData[audioAsset.assetId],
               let time = data["timeBeforePause"] as? TimeInterval {
                restoredTime = time
                data.removeValue(forKey: "timeBeforePause")
                audioAssetData[audioAsset.assetId] = data
            }

            if let data = audioAssetData[audioAsset.assetId], let volume = data["volumeBeforePause"] as? Float {
                restoredVolume = volume
            }

            let finishResume: () -> Void = { [weak self] in
                guard let self else { return }
                if fadeIn {
                    let targetVolume = restoredVolume ?? (audioAsset.channels.first?.volume ?? audioAsset.initialVolume)
                    audioAsset.setVolume(volume: 0, fadeDuration: 0)
                    audioAsset.resume()
                    audioAsset.setVolume(volume: NSNumber(value: targetVolume), fadeDuration: fadeInDuration)
                } else {
                    if let volume = restoredVolume {
                        audioAsset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
                    }
                    audioAsset.resume()
                }
                if var data = self.audioAssetData[audioAsset.assetId] {
                    data.removeValue(forKey: "volumeBeforePause")
                    self.audioAssetData[audioAsset.assetId] = data
                }
                if self.showNotification {
                    self.currentlyPlayingAssetId = audioId
                    self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
                }
                self.notifyPlaybackState(assetId: audioId, reason: "resume", state: .playing, audioAsset: audioAsset)
                call.resolve()
            }

            if let resumeTime = restoredTime {
                audioAsset.setCurrentTime(time: resumeTime) { [weak self] in
                    guard let self else { return }
                    self.audioQueue.async(flags: .barrier, execute: finishResume)
                }
            } else {
                finishResume()
            }
        }
    }

    @objc func pause(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }
            cancelPendingPlay(for: audioAsset.assetId)
            let fadeOut = call.getBool(Constant.FadeOut) ?? false
            let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
            let currentVolume = audioAsset.channels.first?.volume ?? audioAsset.initialVolume
            var data = audioAssetData[audioAsset.assetId] ?? [:]
            data["volumeBeforePause"] = currentVolume

            // Without fade: store position now. With fade: `recordPausePositionAfterFade` runs when the fade finishes.
            if !fadeOut {
                data["timeBeforePause"] = audioAsset.getCurrentTime()
            }
            audioAssetData[audioAsset.assetId] = data

            if fadeOut {
                audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration, toPause: true)
            } else {
                audioAsset.pause()
            }

            // Fade-out: `recordPausePositionAfterFade` updates Now Playing when fade-to-pause completes.
            if self.showNotification && !fadeOut {
                self.updatePlaybackState(isPlaying: false, elapsedTime: audioAsset.getCurrentTime(), duration: audioAsset.getDuration())
            }

            self.endSession()
            if !fadeOut {
                self.notifyPlaybackState(assetId: audioAsset.assetId, reason: "pause", state: .paused, audioAsset: audioAsset)
            }
            call.resolve()
        }
    }

    /// Stops playback of the audio asset identified by `assetId` from the plugin call and performs related cleanup.
    ///
    /// The `assetId` is read from the call using `Constant.AssetIdKey`. If the asset is currently playing it will be stopped. When `showNotification` is enabled and this asset owns Now Playing, playback state is updated to stopped but the Now Playing card is left in place until `unload()` or natural completion. If the asset was created by `playOnce`, it is removed from `playOnceAssets` and its notification metadata is removed. The audio session is ended if appropriate. The call is resolved on success or rejected with an error message on failure.
    @objc func stop(_ call: CAPPluginCall) {
        let audioId = call.getString(Constant.AssetIdKey) ?? ""
        let fadeOut = call.getBool(Constant.FadeOut) ?? false
        let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)

        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard !self.audioList.isEmpty else {
                call.reject("Audio list is empty")
                return
            }

            do {
                // Sample before `stopAudio` — non-fade `AudioAsset.stop()` resets `currentTime` to 0.
                var preStopNowPlayingElapsed: TimeInterval?
                var preStopNowPlayingDuration: TimeInterval?
                if !fadeOut,
                   self.showNotification,
                   self.currentlyPlayingAssetId == audioId,
                   let preStopAsset = self.audioList[audioId] as? AudioAsset {
                    preStopNowPlayingElapsed = preStopAsset.getCurrentTime()
                    preStopNowPlayingDuration = preStopAsset.getDuration()
                }

                try self.stopAudio(audioId: audioId, fadeOut: fadeOut, fadeOutDuration: fadeOutDuration)

                // Keep `currentlyPlayingAssetId` so lock screen / Control Center stays tied to this asset
                // until `unload()` clears it; refresh Now Playing to a stopped state (rate 0).
                // Skip when fading out to stop: `recordStoppedPlaybackStateAfterFade` runs when the fade finishes
                // (and for zero-volume immediate stop inside `stopWithFade`).
                if let elapsed = preStopNowPlayingElapsed,
                   let duration = preStopNowPlayingDuration,
                   self.showNotification,
                   self.currentlyPlayingAssetId == audioId {
                    self.updatePlaybackState(
                        isPlaying: false,
                        elapsedTime: elapsed,
                        duration: duration
                    )
                }

                // Clean up playOnce tracking if this was a playOnce asset
                if self.playOnceAssets.contains(audioId) {
                    self.playOnceAssets.remove(audioId)
                    self.notificationMetadataMap.removeValue(forKey: audioId)
                }

                self.endSession()
                if !fadeOut {
                    if let audioAsset = self.audioList[audioId] as? AudioAsset {
                        self.notifyPlaybackState(assetId: audioId, reason: "stop", state: .stopped, audioAsset: audioAsset)
                    } else {
                        self.notifyPlaybackState(assetId: audioId, reason: "stop", state: .stopped)
                    }
                }
                call.resolve()
            } catch {
                call.reject(error.localizedDescription)
            }
        }
    }

    @objc func loop(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            audioAsset.loop()
            if self.showNotification {
                self.currentlyPlayingAssetId = audioAsset.assetId
                self.updateNowPlayingInfo(audioId: audioAsset.assetId, audioAsset: audioAsset)
                self.updatePlaybackState(isPlaying: true)
            }
            self.notifyPlaybackState(assetId: audioAsset.assetId, reason: "loop", state: .playing, audioAsset: audioAsset)
            call.resolve()
        }
    }

    /// Unloads a previously loaded audio asset identified by `assetId` and removes any associated one-shot tracking or metadata.
    /// - Parameters:
    ///   - call: The plugin call that must include the `assetId` string under the key used by the plugin; on success the call is resolved, on failure the call is rejected (for example if the audio list is empty or the asset cannot be cast/unloaded).
    @objc func unload(_ call: CAPPluginCall) {
        let audioId = call.getString(Constant.AssetIdKey) ?? ""

        audioQueue.sync(flags: .barrier) { // Use barrier for writing operations
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }

            guard !self.audioList.isEmpty else {
                call.reject("Audio list is empty")
                return
            }

            let wasCurrentlyPlaying = self.currentlyPlayingAssetId == audioId

            if let asset = self.audioList[audioId] as? AudioAsset {
                asset.unload()
                self.audioList[audioId] = nil

                // Clean up playOnce tracking if this was a playOnce asset
                if self.playOnceAssets.contains(audioId) {
                    self.playOnceAssets.remove(audioId)
                    self.notificationMetadataMap.removeValue(forKey: audioId)
                }

                if wasCurrentlyPlaying {
                    // This asset controlled the Now Playing / remote command state.
                    self.currentlyPlayingAssetId = nil
                    if self.showNotification {
                        self.clearNowPlayingInfo()
                    }
                }

                self.endSession()
                call.resolve()
            } else if let audioNumber = self.audioList[audioId] as? NSNumber {
                // Also handle unloading system sounds
                AudioServicesDisposeSystemSoundID(SystemSoundID(audioNumber.intValue))
                self.audioList[audioId] = nil

                // Clean up playOnce tracking if this was a playOnce asset
                if self.playOnceAssets.contains(audioId) {
                    self.playOnceAssets.remove(audioId)
                    self.notificationMetadataMap.removeValue(forKey: audioId)
                }

                call.resolve()
            } else {
                call.reject("Cannot cast to AudioAsset")
            }
        }
    }

    @objc func setVolume(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            let volume = min(max(call.getFloat(Constant.Volume) ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
            let durationSecs = call.getDouble(Constant.FadeDuration) ?? 0.0
            audioAsset.setVolume(volume: volume as NSNumber, fadeDuration: durationSecs)
            call.resolve()
        }
    }

    @objc func setRate(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            let rate = min(max(call.getFloat(Constant.Rate) ?? Constant.DefaultRate, Constant.MinRate), Constant.MaxRate)
            audioAsset.setRate(rate: rate as NSNumber)
            call.resolve()
        }
    }

    @objc func isPlaying(_ call: CAPPluginCall) {
        audioQueue.sync {
            self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
            defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
            guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
                call.reject("Failed to get audio asset")
                return
            }

            call.resolve([
                "isPlaying": audioAsset.isPlaying()
            ])
        }
    }

    @objc func clearCache(_ call: CAPPluginCall) {
        DispatchQueue.global(qos: .background).async {
            RemoteAudioAsset.clearCache()
            call.resolve()
        }
    }

    // swiftlint:disable:next cyclomatic_complexity function_body_length
    @objc private func preloadAsset(_ call: CAPPluginCall, isComplex complex: Bool) {
        // Common default values to ensure consistency
        let audioId = call.getString(Constant.AssetIdKey) ?? ""
        let channels: Int?
        let volume: Float?
        var isLocalUrl: Bool = call.getBool("isUrl") ?? false

        if audioId == "" {
            call.reject(Constant.ErrorAssetId)
            return
        }
        var assetPath: String = call.getString(Constant.AssetPathKey) ?? ""

        if assetPath == "" {
            call.reject(Constant.ErrorAssetPath)
            return
        }

        // Store notification metadata if provided
        if let metadata = call.getObject(Constant.NotificationMetadata) {
            var metadataDict: [String: String] = [:]
            if let title = metadata["title"] as? String {
                metadataDict["title"] = title
            }
            if let artist = metadata["artist"] as? String {
                metadataDict["artist"] = artist
            }
            if let album = metadata["album"] as? String {
                metadataDict["album"] = album
            }
            if let artworkUrl = metadata["artworkUrl"] as? String {
                metadataDict["artworkUrl"] = artworkUrl
            }
            if !metadataDict.isEmpty {
                // Store metadata on audioQueue for thread safety
                audioQueue.sync(flags: .barrier) {
                    notificationMetadataMap[audioId] = metadataDict
                }
            }
        }

        if complex {
            volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
            channels = max(call.getInt("channels") ?? Constant.DefaultChannels, 1)
        } else {
            channels = Constant.DefaultChannels
            volume = Constant.DefaultVolume
            isLocalUrl = false
        }

        audioQueue.sync(flags: .barrier) { [self] in
            if audioList.isEmpty {
                audioList = [:]
            }

            if audioList[audioId] != nil {
                call.reject(Constant.ErrorAssetAlreadyLoaded + " - " + audioId)
                return
            }

            var basePath: String?
            if let url = URL(string: assetPath), url.scheme != nil {
                // Check if it's a local file URL or a remote URL
                if url.isFileURL {
                    // Handle local file URL
                    let fileURL = url
                    basePath = fileURL.path

                    if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
                        let audioAsset = AudioAsset(
                            owner: self,
                            withAssetId: audioId, withPath: basePath, withChannels: channels,
                            withVolume: volume)
                        self.audioList[audioId] = audioAsset
                        call.resolve()
                        return
                    }
                } else {
                    // Handle remote URL
                    // Extract headers if provided
                    var headers: [String: String]?
                    if let headersObj = call.getObject("headers") {
                        headers = [:]
                        for (key, value) in headersObj {
                            if let stringValue = value as? String {
                                headers?[key] = stringValue
                            }
                        }
                    }
                    let remoteAudioAsset = RemoteAudioAsset(
                        owner: self,
                        withAssetId: audioId,
                        withPath: assetPath,
                        withChannels: channels,
                        withVolume: volume,
                        withHeaders: headers
                    )
                    self.audioList[audioId] = remoteAudioAsset
                    call.resolve()
                    return
                }
            } else if isLocalUrl == false {
                // Handle public folder
                assetPath = assetPath.starts(with: "public/") ? assetPath : "public/" + assetPath
                let assetPathSplit = assetPath.components(separatedBy: ".")
                if assetPathSplit.count >= 2 {
                    basePath = Bundle.main.path(forResource: assetPathSplit[0], ofType: assetPathSplit[1])
                } else {
                    call.reject("Invalid asset path format: \(assetPath)")
                    return
                }
            } else {
                // Handle local file URL
                let fileURL = URL(fileURLWithPath: assetPath)
                basePath = fileURL.path
            }

            if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
                if !complex {
                    let soundFileUrl = URL(fileURLWithPath: basePath)
                    var soundId = SystemSoundID()
                    let result = AudioServicesCreateSystemSoundID(soundFileUrl as CFURL, &soundId)
                    if result == kAudioServicesNoError {
                        self.audioList[audioId] = NSNumber(value: Int32(soundId))
                    } else {
                        call.reject("Failed to create system sound: \(result)")
                        return
                    }
                } else {
                    let audioAsset = AudioAsset(
                        owner: self,
                        withAssetId: audioId, withPath: basePath, withChannels: channels,
                        withVolume: volume)
                    self.audioList[audioId] = audioAsset
                }
            } else {
                if !FileManager.default.fileExists(atPath: assetPath) {
                    call.reject(Constant.ErrorAssetPath + " - " + assetPath)
                    return
                }
                // Use the original assetPath
                if !complex {
                    let soundFileUrl = URL(fileURLWithPath: assetPath)
                    var soundId = SystemSoundID()
                    let result = AudioServicesCreateSystemSoundID(soundFileUrl as CFURL, &soundId)
                    if result == kAudioServicesNoError {
                        self.audioList[audioId] = NSNumber(value: Int32(soundId))
                    } else {
                        call.reject("Failed to create system sound: \(result)")
                        return
                    }
                } else {
                    let audioAsset = AudioAsset(
                        owner: self,
                        withAssetId: audioId, withPath: assetPath, withChannels: channels,
                        withVolume: volume)
                    self.audioList[audioId] = audioAsset
                }
            }
            call.resolve()
        }
    }
    private func stopAudio(audioId: String, fadeOut: Bool, fadeOutDuration: Double) throws {
        var asset: AudioAsset?

        // Avoid reentrant sync when already on audio queue (e.g. from stop()) to prevent deadlock
        if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
            asset = self.audioList[audioId] as? AudioAsset
        } else {
            audioQueue.sync {
                asset = self.audioList[audioId] as? AudioAsset
            }
        }

        guard let audioAsset = asset else {
            throw MyError.runtimeError(Constant.ErrorAssetNotFound)
        }

        clearAudioAssetData(for: audioId)

        if fadeOut {
            audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration)
        } else {
            audioAsset.stop()
        }
    }

    private func clearAudioAssetData(for audioId: String) {
        audioAssetData[audioId] = nil
    }

    private func cancelPendingPlay(for audioId: String) {
        if let task = pendingPlayTasks[audioId] {
            task.cancel()
            pendingPlayTasks[audioId] = nil
        }
    }

    private func handleFadeOut(for asset: AudioAsset, audioId: String, fadeOutDuration: TimeInterval, fadeOutStartTime: TimeInterval) {
        let duration = asset.getDuration()
        if duration <= 0 || !duration.isFinite {
            logger.warning("Audio asset has no finite duration, skipping fadeOut for %@", audioId)
            return
        }

        var startTime = max(duration - fadeOutDuration, 0)
        if fadeOutStartTime > 0 {
            startTime = fadeOutStartTime
        }

        audioAssetData[audioId] = [
            "fadeOut": true,
            "fadeOutStartTime": startTime,
            "fadeOutDuration": fadeOutDuration
        ]
    }

    internal func executeOnAudioQueue(_ block: @escaping () -> Void) {
        if DispatchQueue.getSpecific(key: queueKey) != nil {
            block()  // Already on queue
        } else {
            if isRunningTests {
                audioQueue.async {
                    block()
                }
            } else {
                audioQueue.sync(flags: .barrier) {
                    block()
                }
            }
        }
    }

    /// Async barrier write — serializes shared-state mutations onto the audio queue
    /// WITHOUT blocking the calling thread. Runs inline when already on the queue
    /// to preserve ordering.
    internal func executeOnAudioQueueAsync(_ block: @escaping () -> Void) {
        if DispatchQueue.getSpecific(key: queueKey) != nil {
            block()
        } else {
            audioQueue.async(flags: .barrier, execute: block)
        }
    }

    /// Use this for read-only access to shared state — avoids the .barrier write lock
    /// that `executeOnAudioQueue` applies, preventing deadlocks with third-party SDKs.
    internal func readOnAudioQueue<T>(_ block: () -> T) -> T {
        if DispatchQueue.getSpecific(key: queueKey) != nil {
            return block()
        }
        return audioQueue.sync { block() }
    }

    @objc func notifyCurrentTime(_ asset: AudioAsset) {
        audioQueue.sync {
            let rawTime = asset.getCurrentTime()
            // Round to nearest 100ms (0.1 seconds)
            let currentTime = round(rawTime * 10) / 10
            notifyListeners("currentTime", data: [
                "currentTime": currentTime,
                "assetId": asset.assetId
            ])

            if let fadeData = audioAssetData[asset.assetId],
               let fadeOut = fadeData["fadeOut"] as? Bool, fadeOut,
               let fadeOutStartTime = fadeData["fadeOutStartTime"] as? Double,
               let fadeOutDuration = fadeData["fadeOutDuration"] as? Double,
               currentTime >= fadeOutStartTime {
                asset.stopWithFade(fadeOutDuration: fadeOutDuration)
                audioAssetData[asset.assetId] = nil
            }
        }
    }

    @objc func getPluginVersion(_ call: CAPPluginCall) {
        call.resolve(["version": self.pluginVersion])
    }

    @objc func deinitPlugin(_ call: CAPPluginCall) {
        // Stop all playing audio
        audioQueue.sync(flags: .barrier) {
            for (_, asset) in self.audioList {
                if let audioAsset = asset as? AudioAsset {
                    audioAsset.stop()
                }
            }
        }

        // Clear notification center
        clearNowPlayingInfo()

        // Restore original audio session settings if we changed them
        if audioSessionInitialized, let originalCategory = originalAudioCategory {
            do {
                // Deactivate our audio session
                try self.session.setActive(false, options: .notifyOthersOnDeactivation)

                // Restore original category and options
                if let originalOptions = originalAudioOptions {
                    try self.session.setCategory(originalCategory, options: originalOptions)
                } else {
                    try self.session.setCategory(originalCategory)
                }

                audioSessionInitialized = false
            } catch {
                print("Failed to restore audio session: \(error)")
            }
        }

        call.resolve()
    }

    // swiftlint:disable cyclomatic_complexity
    /// Updates the system Now Playing information for the specified audio asset.
    ///
    /// Looks up stored metadata for `audioId` and publishes title, artist, album, artwork (if provided),
    /// playback duration, elapsed time, and playback rate to MPNowPlayingInfoCenter. Artwork, when present,
    /// is loaded asynchronously and applied when available.
    /// - Parameters:
    ///   - audioId: The asset identifier used to retrieve Now Playing metadata.
    ///   - audioAsset: The audio asset used to obtain current playback time and duration.

    private func updateNowPlayingInfo(audioId: String, audioAsset: AudioAsset) {
        DispatchQueue.main.async { [weak self] in
            guard let self = self else { return }

            var nowPlayingInfo = [String: Any]()

            // Get metadata from the map (read on audioQueue for thread safety)
            let metadata = self.audioQueue.sync { self.notificationMetadataMap[audioId] }
            if let metadata = metadata {
                if let title = metadata["title"] {
                    nowPlayingInfo[MPMediaItemPropertyTitle] = title
                }
                if let artist = metadata["artist"] {
                    nowPlayingInfo[MPMediaItemPropertyArtist] = artist
                }
                if let album = metadata["album"] {
                    nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album
                }

                // Load artwork if provided
                if let artworkUrl = metadata["artworkUrl"] {
                    let targetAudioId = audioId
                    self.loadArtwork(from: artworkUrl) { [weak self] image in
                        guard let self = self, let image = image else { return }
                        self.audioQueue.async { [weak self] in
                            guard let self = self else { return }
                            guard self.currentlyPlayingAssetId == targetAudioId else { return }

                            DispatchQueue.main.async { [weak self] in
                                guard let self = self else { return }
                                let stillCurrent = self.audioQueue.sync { self.currentlyPlayingAssetId == targetAudioId }
                                guard stillCurrent else { return }

                                var merged = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
                                merged[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in
                                    image
                                }
                                MPNowPlayingInfoCenter.default().nowPlayingInfo = merged
                            }
                        }
                    }
                }
            }

            // Add playback info
            nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = audioAsset.getDuration()
            nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = audioAsset.getCurrentTime()
            nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0

            MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
        }
    }
    // swiftlint:enable cyclomatic_complexity

    /// Clears the Now Playing info when the plugin is no longer the active notifier.
    private func clearNowPlayingInfo() {
        DispatchQueue.main.async {
            MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
        }
    }

    /// Persists `timeBeforePause` and refreshes Now Playing after fade-out-to-pause completes.
    internal func recordPausePositionAfterFade(assetId: String, elapsedTime: TimeInterval, duration: TimeInterval) {
        audioQueue.async { [weak self] in
            guard let self else { return }
            var data = self.audioAssetData[assetId] ?? [:]
            data["timeBeforePause"] = elapsedTime
            self.audioAssetData[assetId] = data
            if self.showNotification && self.currentlyPlayingAssetId == assetId {
                self.updatePlaybackState(isPlaying: false, elapsedTime: elapsedTime, duration: duration)
            }
            if let audioAsset = self.audioList[assetId] as? AudioAsset {
                self.notifyPlaybackState(assetId: assetId, reason: "pause", state: .paused, audioAsset: audioAsset)
            } else {
                self.notifyPlaybackState(assetId: assetId, reason: "pause", state: .paused)
            }
        }
    }

    /// Refreshes Now Playing to a stopped state after fade-out-to-stop completes (or zero-volume stop-with-fade).
    internal func recordStoppedPlaybackStateAfterFade(assetId: String, elapsedTime: TimeInterval, duration: TimeInterval) {
        audioQueue.async { [weak self] in
            guard let self else { return }
            if self.showNotification && self.currentlyPlayingAssetId == assetId {
                self.updatePlaybackState(isPlaying: false, elapsedTime: elapsedTime, duration: duration)
            }
            if let audioAsset = self.audioList[assetId] as? AudioAsset {
                self.notifyPlaybackState(assetId: assetId, reason: "stop", state: .stopped, audioAsset: audioAsset)
            } else {
                self.notifyPlaybackState(assetId: assetId, reason: "stop", state: .stopped)
            }
        }
    }

    private func updatePlaybackState(isPlaying: Bool, elapsedTime: TimeInterval? = nil, duration: TimeInterval? = nil) {
        DispatchQueue.main.async {
            var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
            nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
            if let elapsed = elapsedTime {
                nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed
            }
            if let dur = duration {
                nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = dur
            }
            MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
        }
    }

    /// Loads an image from a local file path or a remote URL and delivers it to the completion handler.
    /// - Parameters:
    ///   - urlString: A string representing either a local file path (plain path or `file://` URL) or a remote URL (e.g., `http://` or `https://`).
    ///   - completion: Called with the loaded `UIImage` on success, or `nil` if the image could not be loaded.
    private func loadArtwork(from urlString: String, completion: @escaping (UIImage?) -> Void) {
        // Check if it's a local file path or URL
        if let url = URL(string: urlString) {
            if url.scheme == nil || url.isFileURL {
                // Local file
                let path = url.path
                if FileManager.default.fileExists(atPath: path) {
                    if let image = UIImage(contentsOfFile: path) {
                        completion(image)
                        return
                    }
                }
            } else {
                // Remote URL
                URLSession.shared.dataTask(with: url) { data, _, _ in
                    if let data = data, let image = UIImage(data: data) {
                        completion(image)
                    } else {
                        completion(nil)
                    }
                }.resume()
                return
            }
        }
        completion(nil)
    }

}
