//
//  YatteeApp.swift
//  Yattee
//
//  Main application entry point.
//

import SwiftUI
import Combine
import CloudKit
import Nuke
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

extension URL: @retroactive Identifiable {
    public var id: String { absoluteString }
}

@main
struct YatteeApp: App {
    #if os(iOS)
    @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    #elseif os(macOS)
    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
    #endif

    @State private var appEnvironment = AppEnvironment.shared
    @State private var backgroundTasksRegistered = false
    @State private var showingClipboardAlert = false
    @State private var detectedClipboardURL: URL?
    @State private var lastCheckedClipboardURL: URL?
    @Environment(\.scenePhase) private var scenePhase

    // Deep link handling state
    @State private var prefilledLinkURL: URL?
    #if !os(tvOS)
    @State private var deepLinkVideo: Video?
    @State private var deepLinkStreams: [Stream] = []
    @State private var deepLinkCaptions: [Caption] = []
    @State private var showingDeepLinkDownloadSheet = false
    #endif

    // First-launch state
    @State private var showingICloudAlert = false
    @State private var showingICloudProgress = false
    @State private var showingLegacyAccountsAlert = false
    @State private var showingLegacyAccountsImport = false
    #if os(iOS)
    @State private var showingSettings = false
    #endif
    @State private var showingOpenLinkSheet = false

    // Ambiguous-link prompts are stored on NavigationCoordinator so they can
    // be dual-hosted by the root app view (here) and the expanded player sheet.

    init() {
        // Configure Nuke image loading pipeline
        ImageLoadingService.shared.configure()

        // Register background tasks early — Apple requires BGTaskScheduler.register()
        // to be called during the app launch sequence, before the run loop starts.
        appEnvironment.backgroundRefreshManager.registerBackgroundTasks()
    }

    var body: some Scene {
        WindowGroup {
            ContentView()
                .appEnvironment(appEnvironment)
                #if !os(tvOS)
                .tint(appEnvironment.settingsManager.resolvedAccentColor)
                #endif
                #if os(macOS)
                .frame(minWidth: 800, minHeight: 500)
                // Required on the view to prevent new windows on URL open
                .handlesExternalEvents(preferring: ["*"], allowing: ["*"])
                #endif
                .onOpenURL { url in
                    handleDeepLink(url)
                }
                .onContinueUserActivity(HandoffManager.activityType) { activity in
                    handleContinuedActivity(activity)
                }
                .onAppear {
                    #if !os(tvOS)
                    SettingsManager.applyTheme(appEnvironment.settingsManager.theme)
                    #endif
                    registerBackgroundTasksIfNeeded()
                    #if os(tvOS)
                    TopShelfSnapshotWriter.startObserving(dataManager: appEnvironment.dataManager)
                    TopShelfSnapshotWriter.writeAll(
                        dataManager: appEnvironment.dataManager,
                        settingsManager: appEnvironment.settingsManager
                    )
                    #endif
                }
                .onReceive(NotificationCenter.default.publisher(for: .continueUserActivity)) { notification in
                    if let activity = notification.object as? NSUserActivity {
                        handleContinuedActivity(activity)
                    }
                }
                #if os(iOS) || os(macOS)
                .alert(String(localized: "alert.openVideo.title"), isPresented: $showingClipboardAlert) {
                    Button(String(localized: "common.open")) {
                        if let url = detectedClipboardURL {
                            appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
                        }
                    }
                    Button(String(localized: "common.cancel"), role: .cancel) {}
                } message: {
                    if let url = detectedClipboardURL {
                        Text(String(localized: "alert.openVideo.message \(url.host ?? "clipboard")"))
                    }
                }
                .sheet(item: $prefilledLinkURL) { url in
                    OpenLinkSheet(prefilledURL: url)
                        .appEnvironment(appEnvironment)
                }
                .sheet(item: Binding<Video?>(
                    get: {
                        // Only host the sheet here when the player is not expanded.
                        // When expanded, ExpandedPlayerSheet hosts it so it appears above the player window.
                        guard !appEnvironment.navigationCoordinator.isPlayerExpanded else { return nil }
                        return appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo
                    },
                    set: { newValue in
                        if newValue == nil {
                            appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = nil
                        }
                    }
                )) { video in
                    QueueActionSheet(video: video)
                        .appEnvironment(appEnvironment)
                }
                #if !os(tvOS)
                .sheet(isPresented: $showingDeepLinkDownloadSheet) {
                    if let video = deepLinkVideo {
                        DownloadQualitySheet(
                            video: video,
                            streams: deepLinkStreams,
                            captions: deepLinkCaptions
                        )
                        .appEnvironment(appEnvironment)
                    }
                }
                #endif
                #endif
                // First-launch iCloud sync prompt
                .alert(
                    String(localized: "settings.icloud.enable.confirmation.title"),
                    isPresented: $showingICloudAlert
                ) {
                    Button(String(localized: "settings.icloud.enable.confirmation.action")) {
                        enableICloudAndWait()
                    }
                    Button(String(localized: "common.cancel"), role: .cancel) {
                        finishFirstLaunchFlow()
                    }
                } message: {
                    Text(String(localized: "settings.icloud.enable.confirmation.message"))
                }
                .alert(
                    String(localized: "migration.accounts.prompt.title"),
                    isPresented: $showingLegacyAccountsAlert
                ) {
                    Button(String(localized: "migration.accounts.prompt.review")) {
                        showingLegacyAccountsImport = true
                    }
                    Button(String(localized: "migration.accounts.prompt.later"), role: .cancel) {}
                } message: {
                    Text(String(localized: "migration.accounts.prompt.message"))
                }
                #if os(macOS)
                .sheet(isPresented: $showingICloudProgress) {
                    ICloudSyncProgressView()
                        .appEnvironment(appEnvironment)
                        .interactiveDismissDisabled()
                }
                #else
                .fullScreenCover(isPresented: $showingICloudProgress) {
                    ICloudSyncProgressView()
                        .appEnvironment(appEnvironment)
                        #if os(tvOS)
                        // tvOS full screen covers have a transparent background,
                        // letting the view below leak through.
                        .background(Color.black.ignoresSafeArea())
                        #endif
                }
                #endif
                #if os(tvOS)
                // tvOS sheets render as a small centered card that cannot fit
                // the sidebar-detail layout — use a full screen cover instead.
                .fullScreenCover(isPresented: $showingLegacyAccountsImport) {
                    NavigationStack {
                        TVSidebarDetailContainer(
                            systemImage: "person.badge.key",
                            title: String(localized: "migration.accounts.title")
                        ) {
                            LegacyAccountsImportView()
                        }
                        .appEnvironment(appEnvironment)
                    }
                    .background(Color.black.ignoresSafeArea())
                }
                #else
                .sheet(isPresented: $showingLegacyAccountsImport) {
                    NavigationStack {
                        LegacyAccountsImportView()
                            .appEnvironment(appEnvironment)
                    }
                    #if os(macOS)
                    .frame(minWidth: 560, minHeight: 560)
                    #endif
                }
                #endif
                #if os(iOS)
                .sheet(isPresented: $showingSettings) {
                    SettingsView()
                        .appEnvironment(appEnvironment)
                }
                #endif
                #if !os(tvOS)
                .sheet(isPresented: $showingOpenLinkSheet) {
                    OpenLinkSheet()
                        .appEnvironment(appEnvironment)
                }
                #endif
                #if os(iOS)
                .onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in
                    showingSettings = true
                }
                #endif
                .onReceive(NotificationCenter.default.publisher(for: .showOpenLinkSheet)) { _ in
                    appEnvironment.navigationCoordinator.isPlayerExpanded = false
                    showingOpenLinkSheet = true
                }
                .onReceive(NotificationCenter.default.publisher(for: .openDescriptionLink)) { notification in
                    if let url = notification.object as? URL {
                        handleDescriptionLink(url)
                    }
                }
                .onReceive(NotificationCenter.default.publisher(for: .promptResolvedShortLink)) { notification in
                    if let url = notification.object as? URL {
                        appEnvironment.navigationCoordinator.resolvedShortLinkPrompt = url
                    }
                }
                .onReceive(NotificationCenter.default.publisher(for: .promptAmbiguousExternalLink)) { notification in
                    if let url = notification.object as? URL {
                        appEnvironment.navigationCoordinator.ambiguousExternalLinkPrompt = url
                    }
                }
                // Present confirmation dialogs at root level only when the expanded
                // player isn't covering it. ExpandedPlayerSheet hosts the same
                // dialogs when it *is* expanded, so they always sit on top.
                .resolvedLinkPrompts(
                    shouldHost: !appEnvironment.navigationCoordinator.isPlayerExpanded,
                    appEnvironment: appEnvironment
                )
        }
        #if os(macOS)
        .windowStyle(.hiddenTitleBar)
        .windowToolbarStyle(.unified)
        .defaultSize(width: 1200, height: 800)
        // Handle URLs in the existing window instead of opening a new one
        .handlesExternalEvents(matching: Set(["*"]))
        #endif
        #if os(iOS) || os(macOS)
        .commands {
            FileCommands()
            PlaybackCommands(appEnvironment: appEnvironment)
            NavigationCommands(appEnvironment: appEnvironment)
        }
        #endif
        #if os(tvOS)
        .onChange(of: scenePhase) { _, newPhase in
            LoggingService.shared.logCloudKit("[ScenePhase] Transition to: \(newPhase)")

            // Handle background playback
            appEnvironment.playerService.handleScenePhase(newPhase)

            // Refresh remote control services after returning from background
            appEnvironment.remoteControlCoordinator.handleScenePhase(newPhase)

            // Handle pending notification navigation and warm cache when becoming active
            if newPhase == .active {
                // Refresh media source password status (Keychain state can change in background)
                appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus()

                // Validate subscription account (auto-correct if invalid)
                appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded()
                SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment)

                // Fetch remote CloudKit changes (catches missed push notifications)
                Task {
                    await appEnvironment.cloudKitSync.fetchRemoteChanges()
                }

                // Start periodic polling as fallback for missed push notifications
                appEnvironment.cloudKitSync.startForegroundPolling()
            }

            // Flush pending CloudKit changes when entering background
            if newPhase == .background {
                appEnvironment.cloudKitSync.stopForegroundPolling()
                // Queue current watch progress so the position reaches other devices
                appEnvironment.playerService.syncWatchProgress()
                Task {
                    await appEnvironment.cloudKitSync.flushPendingChanges()
                }
            }
        }
        #else
        .onChange(of: scenePhase) { _, newPhase in
            LoggingService.shared.logCloudKit("[ScenePhase] Transition to: \(newPhase)")

            // Handle background playback
            appEnvironment.playerService.handleScenePhase(newPhase)

            // Refresh remote control services after returning from background
            appEnvironment.remoteControlCoordinator.handleScenePhase(newPhase)

            #if os(iOS)
            // Notify rotation manager - stops monitoring in background to prevent
            // fullscreen entry while app is not visible
            DeviceRotationManager.shared.handleScenePhase(newPhase)
            #endif

            // Handle pending notification navigation and warm cache when becoming active
            if newPhase == .active {
                appEnvironment.notificationManager.handlePendingNavigation(
                    using: appEnvironment.navigationCoordinator
                )
                // Refresh media source password status (Keychain state can change in background)
                appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus()

                // Validate subscription account (auto-correct if invalid)
                appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded()
                SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment)

                // Check clipboard for external video URLs
                checkClipboardForExternalURL()

                // Fetch remote CloudKit changes (catches missed push notifications)
                Task {
                    await appEnvironment.cloudKitSync.fetchRemoteChanges()
                }

                // Start periodic polling as fallback for missed push notifications
                appEnvironment.cloudKitSync.startForegroundPolling()
            }

            // Flush pending CloudKit changes when entering background
            if newPhase == .background {
                appEnvironment.cloudKitSync.stopForegroundPolling()
                // Queue current watch progress so the position reaches other devices
                appEnvironment.playerService.syncWatchProgress()

                #if os(iOS)
                // Persist pending SwiftData changes and run the CloudKit flush
                // inside a background-task assertion. iOS terminates apps with
                // 0xdead10cc when they hold the SwiftData/SQLite database lock
                // across suspension; committing now releases the lock, and the
                // assertion keeps the app alive until the async flush finishes.
                var backgroundTask: UIBackgroundTaskIdentifier = .invalid
                backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "PersistOnBackground") {
                    if backgroundTask != .invalid {
                        UIApplication.shared.endBackgroundTask(backgroundTask)
                        backgroundTask = .invalid
                    }
                }
                // Commit any pending changes synchronously so no open SQLite
                // transaction is left behind at suspension.
                appEnvironment.dataManager.save()
                Task {
                    await appEnvironment.cloudKitSync.flushPendingChanges()
                    if backgroundTask != .invalid {
                        UIApplication.shared.endBackgroundTask(backgroundTask)
                        backgroundTask = .invalid
                    }
                }

                if appEnvironment.settingsManager.backgroundNotificationsEnabled {
                    appEnvironment.backgroundRefreshManager.scheduleIOSBackgroundRefresh()
                }
                #else
                Task {
                    await appEnvironment.cloudKitSync.flushPendingChanges()
                }
                #endif
            }
        }
        #endif
        #if os(macOS)
        Window(String(localized: "menu.app.settings"), id: "settings") {
            SettingsView(showCloseButton: false)
                .appEnvironment(appEnvironment)
                .frame(minWidth: 600, idealWidth: 900, maxWidth: .infinity, minHeight: 520, idealHeight: 600, maxHeight: .infinity)
        }
        .windowResizability(.contentMinSize)
        .defaultSize(width: 900, height: 600)
        .commands {
            CommandGroup(replacing: .appSettings) {
                SettingsWindowMenuItem()
            }
            #if SPARKLE
            CommandGroup(after: .appInfo) {
                CheckForUpdatesMenuItem()
            }
            #endif
        }
        #endif
    }

    private func registerBackgroundTasksIfNeeded() {
        guard !backgroundTasksRegistered else { return }
        backgroundTasksRegistered = true

        // If notifications are enabled, schedule the first refresh
        #if os(iOS)
        if appEnvironment.settingsManager.backgroundNotificationsEnabled {
            appEnvironment.backgroundRefreshManager.scheduleIOSBackgroundRefresh()
        }
        #endif

        // Auto-delete old history entries based on retention setting
        performHistoryCleanup()

        // Run first-launch tasks: offer iCloud sync, then optionally ask about legacy accounts.
        if !appEnvironment.settingsManager.onboardingCompleted {
            Task {
                await appEnvironment.cloudKitSync.refreshAccountStatus()

                if appEnvironment.cloudKitSync.accountStatus == .available {
                    // Small delay so the main UI has time to settle before the alert.
                    try? await Task.sleep(nanoseconds: 500_000_000)
                    showingICloudAlert = true
                } else {
                    finishFirstLaunchFlow()
                }
            }
        } else {
            presentLegacyAccountsPromptIfNeeded()
        }
    }

    /// Enables iCloud sync and waits for the initial upload to complete,
    /// showing a blocking progress overlay while sync runs.
    private func enableICloudAndWait() {
        showingICloudProgress = true
        Task {
            let settings = appEnvironment.settingsManager
            let cloudKit = appEnvironment.cloudKitSync

            settings.iCloudSyncEnabled = true
            settings.enableAllSyncCategories()

            await cloudKit.enable()
            await cloudKit.performInitialUpload()

            settings.replaceWithiCloudData()
            appEnvironment.instancesManager.replaceWithiCloudData()
            appEnvironment.mediaSourcesManager.replaceWithiCloudData()

            showingICloudProgress = false
            finishFirstLaunchFlow()
        }
    }

    /// Completes first-launch bookkeeping and then shows the one-time legacy account prompt if needed.
    private func finishFirstLaunchFlow() {
        appEnvironment.settingsManager.onboardingCompleted = true
        presentLegacyAccountsPromptIfNeeded()
    }

    /// Shows a one-time prompt for unresolved v1 accounts.
    private func presentLegacyAccountsPromptIfNeeded() {
        guard appEnvironment.legacyMigrationService.shouldShowLegacyAccountsPrompt else { return }

        appEnvironment.legacyMigrationService.markLegacyAccountsPromptShown()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
            showingLegacyAccountsAlert = true
        }
    }

    /// Delete old watch history entries based on the retention setting.
    private func performHistoryCleanup() {
        let retentionDays = appEnvironment.settingsManager.historyRetentionDays
        guard retentionDays > 0 else { return }

        let calendar = Calendar.current
        guard let cutoffDate = calendar.date(byAdding: .day, value: -retentionDays, to: Date()) else {
            return
        }

        appEnvironment.dataManager.clearWatchHistory(olderThan: cutoffDate)
    }

    /// Handle incoming deep link URLs.
    private func handleDeepLink(_ url: URL) {
        let router = URLRouter()
        // Resolve yattee://open?url=… wrappers first so timestamp parsing and
        // sheet prefill below see the real link, not the wrapper.
        let url = router.unwrapped(url)
        guard let destination = router.route(url) else { return }

        let action = appEnvironment.settingsManager.defaultLinkAction

        // Videos get special handling based on link action setting
        if case .video(let source, _) = destination, case .id(let videoID) = source {
            handleVideoDeepLink(videoID: videoID, originalURL: url, action: action)
            return
        }

        // External videos also check setting
        if case .externalVideo(let externalURL) = destination {
            handleExternalVideoDeepLink(externalURL: externalURL, action: action)
            return
        }

        // All other destinations use standard navigation
        appEnvironment.navigationCoordinator.navigate(to: destination)
    }

    /// Handle a link tapped inside a video description.
    /// Video URLs that would interrupt active playback surface the queue sheet;
    /// everything else reuses the standard deep-link pipeline.
    private func handleDescriptionLink(_ url: URL) {
        let router = URLRouter()
        guard let destination = router.route(url) else { return }

        if case .video(let source, _) = destination, case .id(let videoID) = source {
            let queueEnabled = appEnvironment.settingsManager.queueEnabled
            let somethingPlaying = appEnvironment.playerService.state.currentVideo != nil
            if queueEnabled && somethingPlaying {
                Task { await presentQueueSheetFromDeepLink(videoID: videoID) }
                return
            }
        }

        handleDeepLink(url)
    }

    /// Fetch a video by ID and present the queue action sheet for it.
    private func presentQueueSheetFromDeepLink(videoID: VideoID) async {
        guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
            appEnvironment.toastManager.showError(
                String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"),
                subtitle: String(
                    localized: "deepLink.noInstance.subtitle",
                    defaultValue: "No source is configured for this video."
                )
            )
            return
        }

        let toastID = appEnvironment.toastManager.show(
            scopes: [.main, .player],
            category: .loading,
            title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
            subtitle: instance.name,
            autoDismissDelay: 30.0
        )

        do {
            let video = try await appEnvironment.contentService.video(
                id: videoID.videoID,
                instance: instance
            )
            appEnvironment.toastManager.dismiss(id: toastID)
            appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = video
        } catch {
            appEnvironment.toastManager.dismiss(id: toastID)
            appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
        }
    }

    /// Handle video deep link based on default link action setting.
    private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) {
        let startTime = URLRouter().parseTimestamp(originalURL)
        switch action {
        case .open:
            // Play directly (existing behavior)
            Task {
                await playVideoFromDeepLink(videoID: videoID, startTime: startTime)
            }

        case .download:
            #if !os(tvOS)
            // Fetch video and show download sheet
            Task {
                await downloadVideoFromDeepLink(videoID: videoID)
            }
            #else
            // tvOS doesn't support downloads, fall back to open
            Task {
                await playVideoFromDeepLink(videoID: videoID, startTime: startTime)
            }
            #endif

        case .ask:
            // Show OpenLinkSheet with URL pre-filled
            showOpenLinkSheetWithURL(originalURL)
        }
    }

    /// Handle external video deep link based on default link action setting.
    private func handleExternalVideoDeepLink(externalURL: URL, action: DefaultLinkAction) {
        switch action {
        case .open:
            // Navigate to ExternalVideoView (existing behavior)
            appEnvironment.navigationCoordinator.navigate(to: .externalVideo(externalURL))

        case .download:
            #if !os(tvOS)
            // Extract and show download sheet
            Task {
                await downloadExternalVideoFromDeepLink(url: externalURL)
            }
            #else
            // tvOS doesn't support downloads, fall back to open
            appEnvironment.navigationCoordinator.navigate(to: .externalVideo(externalURL))
            #endif

        case .ask:
            // Show OpenLinkSheet with URL pre-filled
            showOpenLinkSheetWithURL(externalURL)
        }
    }

    /// Show OpenLinkSheet with a pre-filled URL.
    private func showOpenLinkSheetWithURL(_ url: URL) {
        prefilledLinkURL = url
    }

    /// Play a video from a deep link.
    private func playVideoFromDeepLink(videoID: VideoID, startTime: TimeInterval? = nil) async {
        LoggingService.shared.info(
            "Deep link play: videoID=\(videoID.videoID) source=\(videoID.source) startTime=\(startTime ?? -1)",
            category: .general
        )
        guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
            LoggingService.shared.warning(
                "Deep link play: no instance configured for source \(videoID.source) — aborting",
                category: .general
            )
            appEnvironment.toastManager.showError(
                String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"),
                subtitle: String(
                    localized: "deepLink.noInstance.subtitle",
                    defaultValue: "No source is configured for this video."
                )
            )
            return
        }
        LoggingService.shared.info(
            "Deep link play: using instance \(instance.url.absoluteString)",
            category: .general
        )

        let toastID = appEnvironment.toastManager.show(
            scopes: [.main, .player],
            category: .loading,
            title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
            subtitle: instance.name,
            autoDismissDelay: 30.0
        )

        do {
            let video = try await appEnvironment.contentService.video(
                id: videoID.videoID,
                instance: instance
            )
            LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general)
            appEnvironment.toastManager.dismiss(id: toastID)
            appEnvironment.playerService.openVideo(video, startTime: startTime, forceStartTime: startTime != nil)
        } catch {
            LoggingService.shared.error(
                "Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",
                category: .general
            )
            appEnvironment.toastManager.dismiss(id: toastID)
            // If video fetch fails, fall back to navigating to the video info view
            appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
        }
    }

    #if !os(tvOS)
    /// Download a video from a deep link.
    private func downloadVideoFromDeepLink(videoID: VideoID) async {
        guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { return }

        do {
            let (video, streams, captions, _) = try await appEnvironment.contentService
                .videoWithProxyStreamsAndCaptionsAndStoryboards(
                    id: videoID.videoID,
                    instance: instance
                )

            await MainActor.run {
                deepLinkVideo = video
                deepLinkStreams = streams
                deepLinkCaptions = captions
                showingDeepLinkDownloadSheet = true
            }
        } catch {
            // Fall back to open on error
            await playVideoFromDeepLink(videoID: videoID)
        }
    }

    /// Download an external video from a deep link.
    private func downloadExternalVideoFromDeepLink(url: URL) async {
        guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
            // No Yattee Server - fall back to open
            appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
            return
        }

        do {
            let (video, streams, captions) = try await appEnvironment.contentService
                .extractURL(url, instance: instance)

            await MainActor.run {
                deepLinkVideo = video
                deepLinkStreams = streams
                deepLinkCaptions = captions
                showingDeepLinkDownloadSheet = true
            }
        } catch {
            // Fall back to open on error
            appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
        }
    }
    #endif

    // MARK: - Handoff

    /// Handle continued activity from Handoff.
    private func handleContinuedActivity(_ activity: NSUserActivity) {
        LoggingService.shared.debug("[Handoff] Received activity: \(activity.activityType) - title: \(activity.title ?? "none")", category: .general)
        LoggingService.shared.debug("[Handoff] UserInfo: \(activity.userInfo ?? [:])", category: .general)

        guard let (destination, playbackTime) = appEnvironment.handoffManager
            .restoreDestination(from: activity) else {
            LoggingService.shared.debug("[Handoff] Failed to restore destination from activity", category: .general)
            return
        }

        LoggingService.shared.debug("[Handoff] Restored destination: \(destination), playbackTime: \(playbackTime ?? -1)", category: .general)

        // For video destinations with playback time, play with resume
        if case .video(let source, _) = destination, case .id(let videoID) = source {
            Task {
                await playVideoFromHandoff(videoID: videoID, startTime: playbackTime)
            }
            return
        }

        // For external video with playback time
        if case .externalVideo = destination, playbackTime != nil {
            // External videos don't support resume time in the same way
            // Just navigate to the destination
            appEnvironment.navigationCoordinator.navigate(to: destination)
            return
        }

        // All other destinations use standard navigation
        appEnvironment.navigationCoordinator.navigate(to: destination)
    }

    /// Play a video from Handoff continuation.
    private func playVideoFromHandoff(videoID: VideoID, startTime: TimeInterval?) async {
        LoggingService.shared.debug("[Handoff] playVideoFromHandoff called for: \(videoID.videoID)", category: .general)

        guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
            LoggingService.shared.debug("[Handoff] No instance available, falling back to navigation", category: .general)
            // Fall back to navigation if no instance available
            appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
            return
        }

        do {
            let video = try await appEnvironment.contentService.video(
                id: videoID.videoID,
                instance: instance
            )
            LoggingService.shared.debug("[Handoff] Video fetched, calling openVideo", category: .general)
            appEnvironment.playerService.openVideo(video, startTime: startTime)

            // Always expand player after handoff (unless PiP is active)
            // We add a small delay to ensure ContentView has set up its .onChange observers
            // (during app launch via Handoff, the trigger can fire before the view is ready)
            #if os(iOS) || os(macOS)
            let isPiPActive = appEnvironment.playerService.state.pipState == .active
            #else
            let isPiPActive = false
            #endif
            LoggingService.shared.debug("[Handoff] isPiPActive: \(isPiPActive), isPlayerExpanded: \(appEnvironment.navigationCoordinator.isPlayerExpanded)", category: .general)
            if !isPiPActive {
                // Small delay to ensure view hierarchy is ready to observe trigger changes
                try? await Task.sleep(for: .milliseconds(100))
                LoggingService.shared.debug("[Handoff] Expanding player from handoff", category: .general)
                appEnvironment.navigationCoordinator.expandPlayer()
            }
        } catch {
            LoggingService.shared.debug("[Handoff] Failed to fetch video: \(error), falling back to navigation", category: .general)
            // If video fetch fails, fall back to navigating to the video info view
            appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
        }
    }

    // MARK: - Clipboard Detection

    /// Check clipboard for external video URLs when app becomes active.
    private func checkClipboardForExternalURL() {
        #if os(iOS)
        guard appEnvironment.settingsManager.clipboardURLDetectionEnabled else { return }

        let clipboardURL: URL?
        if UIPasteboard.general.hasURLs {
            clipboardURL = UIPasteboard.general.url
        } else if let string = UIPasteboard.general.string,
                  let url = URL(string: string),
                  url.scheme == "http" || url.scheme == "https" {
            clipboardURL = url
        } else {
            clipboardURL = nil
        }

        guard let url = clipboardURL else { return }

        // Skip if we already checked this URL
        guard url != lastCheckedClipboardURL else { return }
        lastCheckedClipboardURL = url

        // Skip YouTube URLs - they're handled by regular deep links
        if isYouTubeURL(url) { return }

        // Skip known non-video sites
        if isExcludedHost(url) { return }

        // Only prompt if Yattee Server is configured
        guard appEnvironment.instancesManager.yatteeServerInstance != nil else { return }

        detectedClipboardURL = url
        showingClipboardAlert = true
        #elseif os(macOS)
        guard appEnvironment.settingsManager.clipboardURLDetectionEnabled else { return }

        guard let string = NSPasteboard.general.string(forType: .string),
              let url = URL(string: string),
              url.scheme == "http" || url.scheme == "https" else {
            return
        }

        // Skip if we already checked this URL
        guard url != lastCheckedClipboardURL else { return }
        lastCheckedClipboardURL = url

        // Skip YouTube URLs
        if isYouTubeURL(url) { return }

        // Skip known non-video sites
        if isExcludedHost(url) { return }

        // Only prompt if Yattee Server is configured
        guard appEnvironment.instancesManager.yatteeServerInstance != nil else { return }

        detectedClipboardURL = url
        showingClipboardAlert = true
        #endif
    }

    private func isYouTubeURL(_ url: URL) -> Bool {
        guard let host = url.host?.lowercased() else { return false }
        let youtubeHosts = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"]
        return youtubeHosts.contains(host)
    }

    private func isExcludedHost(_ url: URL) -> Bool {
        guard let host = url.host?.lowercased() else { return true }
        let excludedHosts = [
            "google.com", "www.google.com",
            "bing.com", "www.bing.com",
            "duckduckgo.com",
            "apple.com", "www.apple.com",
            "github.com", "www.github.com"
        ]
        return excludedHosts.contains(host)
    }
}
