import AVFoundation
import CommonCrypto
import Foundation
import UniformTypeIdentifiers

// MARK: - Constants

private let kTag = "[Aviation.Cache]"
private let kCacheDirName = "aviation_media_cache"
private let kMetadataFileName = ".cache_metadata.json"
private let kDefaultMaxSizeBytes: Int64 = 256 * 1024 * 1024 // 256MB
private let kCacheScheme = "aviation-cache"
/// Coalescing window for metadata persistence. Registrations arrive in bursts
/// (one per cached segment/file); rewriting the full JSON on each is wasteful,
/// so writes within this window collapse into a single save.
private let kMetadataSaveDebounceSeconds: TimeInterval = 1.0

// MARK: - CacheAdapter

/**
 * CacheAdapter — Transparent disk caching for media on iOS.
 *
 * Uses `AVAssetResourceLoaderDelegate` to intercept resource loading requests.
 * Media URLs are rewritten from their original scheme (https://) to a custom
 * scheme (`aviation-cache-<scheme>://`), which triggers the delegate. The delegate
 * checks the disk cache first. On cache hit, data is served directly from disk.
 * On cache miss, the delegate fetches from the original URL via URLSession,
 * writes to disk, and forwards data to AVPlayer.
 *
 * For HLS content, each segment request flows through the delegate independently,
 * enabling segment-level caching. For progressive content, the entire file is cached.
 *
 * Architecture:
 *   HybridPlaybackEngine decides per-load whether to cache
 *     -> Calls cacheAdapter.createCacheAwareAsset(url:headers:)
 *     -> Returns AVURLAsset with custom scheme + delegate set
 *     -> AVPlayer requests resources via the delegate
 *     -> Delegate serves from cache or fetches + caches from network
 *
 * Threading:
 *   The resource loader delegate runs on a dedicated serial queue
 *   (`resourceLoaderQueue`) to avoid deadlocking with AVPlayer's internal locks.
 *   File I/O and URLSession tasks also use this queue.
 */
class CacheAdapter: NSObject {
    // MARK: - Properties

    private let cacheDir: URL
    private var maxSizeBytes: Int64
    private let resourceLoaderQueue = DispatchQueue(label: "aviation.cache.resourceLoader")
    /// Dedicated low-priority queue for metadata persistence. Kept separate from
    /// `resourceLoaderQueue` so a debounced JSON write never sits ahead of an
    /// AVPlayer resource request (which would add latency when loading an item).
    private let metadataQueue = DispatchQueue(label: "aviation.cache.metadata", qos: .utility)

    /// Event bus pointer for emitting cache hit/miss events through the C event bus.
    private var eventBusPtr: OpaquePointer?

    /// Whether caching is enabled. Defaults to false — caching is opt-in via
    /// CachePlugin or player.setCacheEnabled(true). When false, the interceptor
    /// passes through without action.
    private(set) var cacheEnabled: Bool = false

    /// Track active URLSession tasks so they can be cancelled if the loading request is cancelled.
    private var activeTasks: [URLSessionDataTask] = []
    /// Cache keys with an in-flight background full-fetch, to avoid launching
    /// duplicate whole-file downloads for the same resource.
    private var backgroundFetchKeys: Set<String> = []
    private let taskLock = NSLock()

    // MARK: - LRU Metadata

    /// Cache entry metadata for LRU eviction.
    private struct CacheEntry: Codable {
        var key: String
        var sizeBytes: Int64
        var lastAccessDate: Date
        var contentType: String?
    }

    /// In-memory LRU metadata, persisted to disk periodically.
    private var metadata: [String: CacheEntry] = [:]
    private let metadataLock = NSLock()
    /// Whether a coalesced metadata save is already pending. Guarded by `metadataLock`.
    private var metadataSaveScheduled = false

    // MARK: - Init

    init(maxSizeBytes: Int64 = kDefaultMaxSizeBytes) {
        let cachesDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
        cacheDir = cachesDir.appendingPathComponent(kCacheDirName)
        self.maxSizeBytes = maxSizeBytes

        super.init()

        // Create cache directory if needed
        try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)

        // Load persisted metadata
        loadMetadata()

        #if DEBUG
            NSLog("%@ initialized: dir=%@, maxSize=%lldMB", kTag, cacheDir.path, maxSizeBytes / 1024 / 1024)
        #endif
    }

    // MARK: - Public API

    /**
     * Create a cache-aware AVURLAsset for the given URL.
     *
     * Rewrites the URL scheme to `aviation-cache-<scheme>://` so AVPlayer routes all
     * resource loading requests through our delegate. The original scheme is
     * encoded in the URL for retrieval during loading.
     *
     * - Parameters:
     *   - url: The original media URL (https://, http://, etc.)
     *   - headers: Optional HTTP headers to include in network requests
     * - Returns: An AVURLAsset configured with the resource loader delegate
     */
    func createCacheAwareAsset(url: URL, headers: [String: String]?) -> AVURLAsset {
        let cacheURL = rewriteURLToCacheScheme(url)
        #if DEBUG
            NSLog("%@ createCacheAwareAsset original=%@ rewritten=%@", kTag, url.absoluteString, cacheURL.absoluteString)
        #endif

        // Store headers for this URL so we can use them during network requests
        let cacheKey = Self.cacheKey(for: url)
        if let headers, !headers.isEmpty {
            headersStore[cacheKey] = headers
        }

        let asset = AVURLAsset(url: cacheURL)
        asset.resourceLoader.setDelegate(self, queue: resourceLoaderQueue)
        return asset
    }

    /// Current cache size in bytes.
    func getCacheSizeBytes() -> Int64 {
        metadataLock.lock()
        let size = metadata.values.reduce(0) { $0 + $1.sizeBytes }
        metadataLock.unlock()
        return size
    }

    /// Clear all cached content.
    func clearCache() {
        metadataLock.lock()
        metadata.removeAll()
        metadataLock.unlock()

        try? FileManager.default.removeItem(at: cacheDir)
        try? FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true)
        saveMetadata()
        #if DEBUG
            NSLog("%@ cache cleared", kTag)
        #endif
    }

    /// Cancel all in-flight network requests and drop references.
    /// Call during engine teardown so pending completions free their resources
    /// promptly rather than running to completion on dropped handlers.
    func teardown() {
        taskLock.lock()
        let cancelled = activeTasks.count
        for task in activeTasks {
            task.cancel()
        }
        activeTasks.removeAll()
        taskLock.unlock()

        // Flush any coalesced metadata write so a pending save isn't lost.
        saveMetadata()

        #if DEBUG
            if cancelled > 0 {
                NSLog("%@ teardown — cancelled %d in-flight network tasks", kTag, cancelled)
            }
        #endif
    }

    deinit {
        // Defensive: if teardown() was not called explicitly, still cancel pending tasks.
        taskLock.lock()
        for task in activeTasks {
            task.cancel()
        }
        activeTasks.removeAll()
        taskLock.unlock()
    }

    /// Set the C event bus pointer for cache hit/miss event emission.
    func setEventBus(_ bus: OpaquePointer?) {
        eventBusPtr = bus
    }

    /// Enable or disable caching. When disabled, the interceptor passes through.
    func setCacheEnabled(_ enabled: Bool) {
        cacheEnabled = enabled
    }

    /// Update the maximum cache size. Enforced as eviction threshold.
    /// If current cache exceeds this, oldest entries are evicted immediately.
    func setMaxSizeBytes(_ bytes: Int64) {
        maxSizeBytes = bytes
        evictIfNeeded()
    }

    /// Check whether a URI is currently cached on disk.
    func isCached(uri: String) -> Bool {
        guard let url = URL(string: uri) else { return false }
        let key = Self.cacheKey(for: url)
        let filePath = cacheFilePath(for: key)
        return FileManager.default.fileExists(atPath: filePath.path)
    }

    /// On-disk destination for a URL's cached content. Used by the preload
    /// adapter to stream L1 downloads directly into the cache.
    func cachedFileURL(for url: URL) -> URL {
        cacheFilePath(for: Self.cacheKey(for: url))
    }

    /**
     * Register a file that was written into the cache directory by an
     * external producer (the preload adapter's L1 download). Without a
     * metadata entry the file is invisible to size accounting and LRU
     * eviction, and cache hits lose their content type.
     */
    func registerCachedFile(for url: URL, sizeBytes: Int64, mimeType: String?) {
        let key = Self.cacheKey(for: url)

        // clearCache() can race the preload's move+register — skip if the file is gone.
        guard FileManager.default.fileExists(atPath: cacheFilePath(for: key).path) else { return }

        let entry = CacheEntry(
            key: key,
            sizeBytes: sizeBytes,
            lastAccessDate: Date(),
            contentType: Self.utiFromMIME(mimeType) ?? Self.utiFromURL(url)
        )

        metadataLock.lock()
        metadata[key] = entry
        metadataLock.unlock()

        evictIfNeeded()
        scheduleSaveMetadata()
    }

    // MARK: - URL Scheme Rewriting

    /// Headers stored per cache key for network requests.
    private var headersStore: [String: [String: String]] = [:]

    /// Rewrite URL: `https://example.com/file.mp3` → `aviation-cache-https://example.com/file.mp3`
    ///
    /// The original scheme is encoded into the CUSTOM SCHEME (as a suffix), and
    /// the host/path are left untouched. Do NOT encode the scheme into the host
    /// (e.g. `aviation-cache://https__example.com/...`): underscores make the
    /// hostname invalid per RFC 3986, and iOS 17+/CFNetwork reject such URLs as
    /// "unsupported URL" (NSURLError -1002) before the resource-loader delegate
    /// is ever consulted — which breaks playback of any cached (audio) item.
    private func rewriteURLToCacheScheme(_ url: URL) -> URL {
        guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return url
        }
        let originalScheme = components.scheme ?? "https"
        // Format: <kCacheScheme>-<ORIGINAL_SCHEME>://host/path
        components.scheme = "\(kCacheScheme)-\(originalScheme)"
        return components.url ?? url
    }

    /// Restore the original URL from a cache-scheme URL.
    /// `aviation-cache-https://example.com/file.mp3` → `https://example.com/file.mp3`
    private func restoreOriginalURL(from cacheURL: URL) -> URL? {
        let prefix = "\(kCacheScheme)-"
        guard var components = URLComponents(url: cacheURL, resolvingAgainstBaseURL: false),
              let scheme = components.scheme,
              scheme.hasPrefix(prefix)
        else {
            return nil
        }

        components.scheme = String(scheme.dropFirst(prefix.count))
        return components.url
    }

    // MARK: - Cache Key

    /// Generate a stable cache key from a URL using SHA256.
    static func cacheKey(for url: URL) -> String {
        let urlString = url.absoluteString
        let data = Data(urlString.utf8)
        var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
        data.withUnsafeBytes { buffer in
            _ = CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &hash)
        }
        return hash.map { String(format: "%02x", $0) }.joined()
    }

    /// File path for a cached resource.
    private func cacheFilePath(for key: String) -> URL {
        cacheDir.appendingPathComponent(key)
    }

    // MARK: - Cache Read/Write

    /// Cached content with its metadata.
    struct CachedContent {
        let data: Data
        let contentType: String? // UTI
    }

    /// Check if data exists in cache for the given URL.
    private func cachedContent(for url: URL) -> CachedContent? {
        let key = Self.cacheKey(for: url)
        let filePath = cacheFilePath(for: key)

        guard FileManager.default.fileExists(atPath: filePath.path) else {
            return nil
        }

        // Update LRU access time and read content type
        metadataLock.lock()
        metadata[key]?.lastAccessDate = Date()
        let contentType = metadata[key]?.contentType
        metadataLock.unlock()

        // Mapped, not copied: many small range windows are served per playback.
        guard let data = try? Data(contentsOf: filePath, options: .mappedIfSafe) else { return nil }
        return CachedContent(data: data, contentType: contentType)
    }

    /// Write data to cache for the given URL.
    /// - Parameter mimeType: The MIME type from the HTTP response, converted to UTI for storage.
    private func writeToCache(data: Data, for url: URL, mimeType: String?) {
        let filePath = cacheFilePath(for: Self.cacheKey(for: url))

        do {
            try data.write(to: filePath)
            // The metadata tail (entry, LRU accounting, persistence) is identical
            // to registering an externally-produced file, so delegate to it.
            registerCachedFile(for: url, sizeBytes: Int64(data.count), mimeType: mimeType)
        } catch {
            NSLog("%@ failed to write cache: %@", kTag, error.localizedDescription)
        }
    }

    // MARK: - LRU Eviction

    /// Evict oldest-accessed entries until total size is under the limit.
    private func evictIfNeeded() {
        metadataLock.lock()
        var totalSize = metadata.values.reduce(0) { $0 + $1.sizeBytes }

        guard totalSize > maxSizeBytes else {
            metadataLock.unlock()
            return
        }

        // Sort by last access date, oldest first
        var entries = metadata.values.sorted { $0.lastAccessDate < $1.lastAccessDate }
        metadataLock.unlock()

        while totalSize > maxSizeBytes, let oldest = entries.first {
            entries.removeFirst()
            let filePath = cacheFilePath(for: oldest.key)
            try? FileManager.default.removeItem(at: filePath)

            metadataLock.lock()
            metadata.removeValue(forKey: oldest.key)
            metadataLock.unlock()

            totalSize -= oldest.sizeBytes
        }
    }

    // MARK: - Metadata Persistence

    private func loadMetadata() {
        let metadataFile = cacheDir.appendingPathComponent(kMetadataFileName)
        guard let data = try? Data(contentsOf: metadataFile),
              let entries = try? JSONDecoder().decode([CacheEntry].self, from: data)
        else {
            return
        }
        metadataLock.lock()
        metadata = Dictionary(uniqueKeysWithValues: entries.map { ($0.key, $0) })
        metadataLock.unlock()
    }

    private func saveMetadata() {
        metadataLock.lock()
        metadataSaveScheduled = false
        let entries = Array(metadata.values)
        metadataLock.unlock()

        let metadataFile = cacheDir.appendingPathComponent(kMetadataFileName)
        if let data = try? JSONEncoder().encode(entries) {
            try? data.write(to: metadataFile)
        }
    }

    /// Coalesce bursty metadata writes into a single deferred save. Multiple
    /// calls within `kMetadataSaveDebounceSeconds` collapse into one write on
    /// the resource-loader queue. Use for high-frequency registrations;
    /// infrequent, must-persist operations (clear, teardown) call
    /// `saveMetadata()` directly.
    private func scheduleSaveMetadata() {
        metadataLock.lock()
        if metadataSaveScheduled {
            metadataLock.unlock()
            return
        }
        metadataSaveScheduled = true
        metadataLock.unlock()

        metadataQueue.asyncAfter(deadline: .now() + kMetadataSaveDebounceSeconds) { [weak self] in
            self?.saveMetadata()
        }
    }

    // MARK: - Network Fetch

    /// Fetch data from the original URL via URLSession.
    private func fetchFromNetwork(
        url: URL,
        headers: [String: String]?,
        loadingRequest: AVAssetResourceLoadingRequest
    ) {
        var request = URLRequest(url: url)
        headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }

        // Range strategy:
        //
        // Content-info probes MUST be answered fast. AVPlayer gives a custom-
        // scheme probe only a few seconds before failing the item with
        // "unsupported URL" (-1002), so downloading the whole file to answer
        // the probe breaks any item whose fetch outlives that window. A tiny
        // ranged request returns a 206 whose Content-Range carries the total
        // size ("bytes 0-1/12345678"), which is all the probe needs.
        //
        // Data requests use the exact requested window; open-ended for
        // requests-to-end so playback can start at nonzero offsets.
        let isContentInfoProbe = loadingRequest.contentInformationRequest != nil
        if isContentInfoProbe {
            let start = loadingRequest.dataRequest?.requestedOffset ?? 0
            let length = Int64(max(loadingRequest.dataRequest?.requestedLength ?? 2, 2))
            request.setValue("bytes=\(start)-\(start + length - 1)", forHTTPHeaderField: "Range")
        } else if let dataRequest = loadingRequest.dataRequest {
            let start = dataRequest.requestedOffset
            if dataRequest.requestsAllDataToEndOfResource {
                if start > 0 {
                    request.setValue("bytes=\(start)-", forHTTPHeaderField: "Range")
                }
            } else {
                let length = Int64(dataRequest.requestedLength)
                request.setValue("bytes=\(start)-\(start + length - 1)", forHTTPHeaderField: "Range")
            }
        }

        // Playback that starts at a nonzero offset (seek/resume) issues ranged
        // data requests that return 206 bodies — those are never written to the
        // cache, so the item would re-fetch from network on every play. Kick off
        // a one-shot background download of the whole file to populate the cache.
        // The offset-0/to-end request that drives normal sequential playback
        // returns a 200 and caches directly, so it's excluded; content-info
        // probes are excluded too (the real data request follows).
        if !isContentInfoProbe,
           let dataRequest = loadingRequest.dataRequest,
           !(dataRequest.requestedOffset == 0 && dataRequest.requestsAllDataToEndOfResource)
        {
            startBackgroundFullFetchIfNeeded(url: url, headers: headers)
        }

        var taskRef: URLSessionDataTask?
        let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            if let taskRef { self?.removeActiveTask(taskRef) }
            guard !loadingRequest.isCancelled else { return }

            if let error {
                // Cancellations are expected (e.g. user seeks away) — log everything else
                // at warn level so release-build cache failures are observable, not silent.
                let nsErr = error as NSError
                if nsErr.domain != NSURLErrorDomain || nsErr.code != NSURLErrorCancelled {
                    NSLog("%@ fetch failed for %@: %@", kTag, url.absoluteString, error.localizedDescription)
                }
                loadingRequest.finishLoading(with: error)
                return
            }

            guard let data, let httpResponse = response as? HTTPURLResponse else {
                NSLog("%@ fetch failed for %@: no data received", kTag, url.absoluteString)
                loadingRequest.finishLoading(with: NSError(
                    domain: "Aviation.Cache",
                    code: -1,
                    userInfo: [NSLocalizedDescriptionKey: "No data received"]
                ))
                return
            }

            // Fill content information if requested
            if let contentInfoRequest = loadingRequest.contentInformationRequest {
                contentInfoRequest.contentType = CacheAdapter.utiFromMIME(httpResponse.mimeType) ?? CacheAdapter.utiFromURL(url)

                // For 206 (partial) responses, parse Content-Range for total size:
                //   "bytes 0-1/12345678" → total = 12345678
                // For 200 (full) responses, use Content-Length directly.
                if httpResponse.statusCode == 206,
                   let contentRange = httpResponse.value(forHTTPHeaderField: "Content-Range"),
                   let slashIndex = contentRange.lastIndex(of: "/")
                {
                    let totalStr = contentRange[contentRange.index(after: slashIndex)...]
                    if let total = Int64(totalStr) {
                        contentInfoRequest.contentLength = total
                    }
                } else if httpResponse.statusCode == 200,
                          let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
                          let length = Int64(contentLength)
                {
                    // Content-Length is the full size only on a 200; on a 206
                    // probe it's the partial body (2 bytes) — leave unset then.
                    contentInfoRequest.contentLength = length
                }
                contentInfoRequest.isByteRangeAccessSupported = true
            }

            // A 200 means the server sent the entire resource regardless of any
            // Range header we set. Cache it, and slice the body to the requested
            // window so the bytes we respond with sit at the offset AVPlayer
            // asked for. A 206 body already matches the requested range.
            var body = data
            if httpResponse.statusCode == 200 {
                self?.writeToCache(data: data, for: url, mimeType: httpResponse.mimeType)

                if let dataRequest = loadingRequest.dataRequest {
                    let start = Int(dataRequest.requestedOffset)
                    if start >= data.count {
                        body = Data()
                    } else {
                        let end = dataRequest.requestsAllDataToEndOfResource
                            ? data.count
                            : min(start + dataRequest.requestedLength, data.count)
                        body = data.subdata(in: start ..< end)
                    }
                }
            }

            if !body.isEmpty {
                loadingRequest.dataRequest?.respond(with: body)
            }

            loadingRequest.finishLoading()
        }

        taskRef = task
        taskLock.lock()
        activeTasks.append(task)
        taskLock.unlock()

        task.resume()
    }

    /// Remove a finished task from the active set. Called from each task's
    /// completion handler so the array doesn't grow unbounded across a session.
    private func removeActiveTask(_ task: URLSessionDataTask) {
        taskLock.lock()
        activeTasks.removeAll { $0 === task }
        taskLock.unlock()
    }

    /// One-shot background download of the entire resource so a playback that
    /// started at a nonzero offset still populates the cache. Deduplicated per
    /// cache key and skipped if the file is already on disk.
    private func startBackgroundFullFetchIfNeeded(url: URL, headers: [String: String]?) {
        let key = Self.cacheKey(for: url)
        guard !FileManager.default.fileExists(atPath: cacheFilePath(for: key).path) else { return }

        taskLock.lock()
        if backgroundFetchKeys.contains(key) {
            taskLock.unlock()
            return
        }
        backgroundFetchKeys.insert(key)
        taskLock.unlock()

        // No Range header → the server returns the full 200 body.
        var request = URLRequest(url: url)
        headers?.forEach { request.setValue($1, forHTTPHeaderField: $0) }

        var taskRef: URLSessionDataTask?
        let task = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            guard let self else { return }
            if let taskRef { removeActiveTask(taskRef) }
            taskLock.lock()
            backgroundFetchKeys.remove(key)
            taskLock.unlock()

            guard error == nil,
                  let data,
                  let httpResponse = response as? HTTPURLResponse,
                  httpResponse.statusCode == 200
            else { return }

            writeToCache(data: data, for: url, mimeType: httpResponse.mimeType)
        }

        taskRef = task
        taskLock.lock()
        activeTasks.append(task)
        taskLock.unlock()

        task.resume()
    }
}

// MARK: - AVAssetResourceLoaderDelegate

extension CacheAdapter: AVAssetResourceLoaderDelegate {
    func resourceLoader(
        _: AVAssetResourceLoader,
        shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
    ) -> Bool {
        guard let requestURL = loadingRequest.request.url else {
            NSLog("%@ resourceLoader called with nil request URL", kTag)
            return false
        }
        guard let originalURL = restoreOriginalURL(from: requestURL) else {
            NSLog("%@ resourceLoader could not restore original URL", kTag)
            return false
        }
        #if DEBUG
            NSLog("%@ resourceLoader request=%@ restored=%@", kTag, requestURL.absoluteString, originalURL.absoluteString)
        #endif

        // Check disk cache first
        if let cached = cachedContent(for: originalURL) {
            // Cache hit — serve from disk
            if let contentInfoRequest = loadingRequest.contentInformationRequest {
                contentInfoRequest.contentLength = Int64(cached.data.count)
                contentInfoRequest.isByteRangeAccessSupported = true
                // Set content type from stored UTI, or infer from URL extension
                contentInfoRequest.contentType = cached.contentType ?? Self.utiFromURL(originalURL)
            }

            if let dataRequest = loadingRequest.dataRequest {
                let offset = Int(dataRequest.requestedOffset)
                let length = dataRequest.requestedLength
                let end = min(offset + length, cached.data.count)
                if offset < cached.data.count {
                    let range = offset ..< end
                    dataRequest.respond(with: cached.data.subdata(in: range))
                }
            }

            loadingRequest.finishLoading()
            return true
        }

        // Cache miss — fetch from network
        let cacheKey = Self.cacheKey(for: originalURL)
        let headers = headersStore[cacheKey]
        fetchFromNetwork(url: originalURL, headers: headers, loadingRequest: loadingRequest)
        return true
    }

    func resourceLoader(
        _: AVAssetResourceLoader,
        didCancel _: AVAssetResourceLoadingRequest
    ) {
        // Cancel any active tasks associated with this request
        taskLock.lock()
        activeTasks.removeAll { $0.state == .completed || $0.state == .canceling }
        taskLock.unlock()
    }
}

// MARK: - CommandInterceptor

extension CacheAdapter: CommandInterceptor {
    func intercept(event _: AviationCommandEvent, context: LoadContext, next: @escaping () -> Void) {
        // Skip entirely if caching disabled — no events
        guard cacheEnabled else { next(); return }

        // Skip DRM, live, video, and adaptive streaming content — by-design exclusions.
        //
        // Video: AVAssetResourceLoaderDelegate proxies the entire progressive download
        //   into memory, which fails for large MP4 files.
        //   Android cache handles video fine via CacheDataSource (byte-range aware).
        //
        // Adaptive streams (HLS .m3u8 / DASH .mpd): The resource loader delegate only
        //   intercepts the initial manifest request (custom scheme). Segment URLs inside
        //   the manifest use the original scheme and bypass the delegate, making caching
        //   ineffective. Worse, rewriting the scheme can interfere with AVPlayer's
        //   internal adaptive streaming engine, causing crashes when switching media types.
        guard !context.hasDrm, !context.isLive, !context.isVideo, !context.isAdaptiveStream else { next(); return }

        guard let url = URL(string: context.uri) else { next(); return }

        // Local files are already on disk. Rewriting their scheme routes them
        // through the resource loader, which only knows how to fetch over the
        // network — the item then fails to load. Recordings played back from
        // disk hit exactly this path.
        guard !url.isFileURL else { next(); return }

        // Check cache hit
        let cacheKey = Self.cacheKey(for: url)
        let filePath = cacheFilePath(for: cacheKey)

        if FileManager.default.fileExists(atPath: filePath.path) {
            context.cacheHit = true
            metadataLock.lock()
            let entry = metadata[cacheKey]
            metadataLock.unlock()
            let sizeBytes = entry?.sizeBytes ?? 0
            emitCacheEvent(code: AviationExtCode.cacheHit, uri: context.uri, bytes: sizeBytes)
        } else {
            emitCacheEvent(code: AviationExtCode.cacheMiss, uri: context.uri, bytes: 0)
        }

        // Create cache-aware asset and store in context
        context.preparedAsset = createCacheAwareAsset(url: url, headers: context.nativeItem?.headers)

        next()
    }
}

// MARK: - UTI Helpers

extension CacheAdapter {
    /// Convert a MIME type string to a UTI string for AVAssetResourceLoadingContentInformationRequest.
    static func utiFromMIME(_ mimeType: String?) -> String? {
        guard let mimeType, !mimeType.isEmpty else { return nil }
        if let utType = UTType(mimeType: mimeType) {
            return utType.identifier
        }
        return nil
    }

    /// Infer a UTI from a URL's file extension (fallback when MIME type is unavailable).
    static func utiFromURL(_ url: URL) -> String? {
        let ext = url.pathExtension.lowercased()
        guard !ext.isEmpty else { return nil }
        if let utType = UTType(filenameExtension: ext) {
            return utType.identifier
        }
        return nil
    }
}

// MARK: - Cache Event Emission

extension CacheAdapter {
    /// Emit a cache hit/miss as adapter telemetry through the C event bus
    /// (AVIATION_EXT_CACHE_*; slots in aviation_extension_events.h).
    private func emitCacheEvent(code: Int32, uri: String, bytes: Int64) {
        guard let bus = eventBusPtr else { return }
        AviationExt.emit(
            AviationExt.event(code: code, values: [bytes], text: uri),
            on: bus
        )
    }
}
