import APIClient
import AVFoundation
import ComponentLibrary
import ComposableArchitecture
import FeatureHooksModels
import FeatureHookSnippetSelection
import FeatureHooksPost
import FeatureToasts
import FeatureVideoTrimmer
import Foundation
import HooksPlayerClient
import Localization
import LyricsClient
import SnippetPlayerClient
import StatsigClient
import SwiftUI
import Utilities
import Waveform

// swiftlint:disable file_length

@Reducer
public struct HooksEditor {
    @Reducer(state: .equatable)
    public enum Destination {
        case mediaPicker
        case volumeSelector(HooksVolumeSelector)
        case simpleSnippetSelection(HookSnippetSelectionReducer)
    }

    @Reducer
    public struct InlineEditing {
        @ObservableState
        @CasePathable
        public enum State: Equatable {
            case trimmer(VideoTrimmerV2.State)
            case snippet(HooksEditorSnippetSelectionReducer.State)
        }

        @CasePathable
        public enum Action {
            case trimmer(VideoTrimmerV2.Action)
            case snippet(HooksEditorSnippetSelectionReducer.Action)
        }

        public init() {}

        public var body: some ReducerOf<Self> {
            Scope(state: \.trimmer, action: \.trimmer) {
                VideoTrimmerV2()
            }
            Scope(state: \.snippet, action: \.snippet) {
                HooksEditorSnippetSelectionReducer()
            }
            Reduce { _, action in
                switch action {
                case .trimmer, .snippet:
                    return .none
                }
            }
        }
    }

    @ObservableState
    public struct State: Equatable {
        @Presents public var destination: Destination.State?

        var clip: Clip
        let maxSnippetDuration: Double = HooksConstants.maxHookMediaDuration

        var initialLocalVideoURL: URL

        var maxRecordingLimit: TimeInterval
        var minRecordingLimit: TimeInterval

        var capturedLocalURL: URL?
        var capturedDuration: Double?
        var originalStartTime: Double = 0
        var originalEndTime: Double = 0 {
            didSet {
                // Only update snippet end time if we have a valid duration
                if originalEndTime > 0 {
                    let videoDuration = originalEndTime - originalStartTime
                    snippetEndTime = min(
                        snippetStartTime + maxSnippetDuration,
                        snippetStartTime + videoDuration,
                        clip.duration
                    )
                }
            }
        }

        // Trimmed video
        var trimmedLocalURL: URL?
        var trimmedStartTime: Double?
        var trimmedEndTime: Double?
        var hookThumbnail: UIImage?

        // Shared video preview resources used by both capture and trimming
        var videoPreviewAsset: AVAsset?
        var videoPreviewPlayer: AVPlayer?

        // Snippet selection
        var snippetStartTime: Double
        var snippetEndTime: Double

        var showTrimmer: Bool = false
        public var selectedMovie: Movie?
        public var mediaTransferProgress: Double = 0

        // Video player state
        var isVideoPlaying: Bool = true
        var isVideoMuted: Bool = false
        var hasNavigatedAway: Bool = false

        // UI state
        var actionButtonsEnabled: Bool = true
        var isScrubbing: Bool = false
        var isDraggingWaveform: Bool = false

        // Audio levels
        var videoVolumeLevel: Float = 0.5
        var snippetVolumeLevel: Float = 1.0

        // Waveform data for snippet selection
        var waveformDataLoadState: LoadValueState<WaveformData> = .loading

        var isTopNavigationShown: Bool = true

        var toast: ToastReducer.State.ToastType?

        // Tooltip state
        var currentTooltipConfig: HooksCaptureTooltip.Configuration?
        @Shared(.appStorage(.hasSeenHooksCaptureAdjustSongTooltip)) var hasSeenAdjustSongTooltip: Bool = false
        @Shared(.appStorage(.hasSeenHooksCaptureAdjustAudioTooltip)) var hasSeenAdjustAudioTooltip: Bool = false

        // Audio/Video synchronization
        var restartVideoTrigger: Int = 0
        var hasTriggeredRestartForCurrentLoop: Bool = false

        var inlineEditing: InlineEditing.State?
        var isInlineEditingActive: Bool { inlineEditing != nil }

        var isPostCapture: Bool {
            capturedLocalURL != nil
        }

        // Current time within the effective loop (seconds)
        var displayTime: Double = 0

        var progress: CGFloat {
            guard effectiveLoopDuration > 0, !effectiveLoopDuration.isNaN, !effectiveLoopDuration.isInfinite else {
                return 0
            }
            return CGFloat(displayTime / effectiveLoopDuration)
        }

        var isOnCamera: Bool {
            return capturedLocalURL == nil
        }

        var isFirstScreen: Bool {
            return !initialLocalVideoURL.absoluteString.isEmpty
        }

        // Marks which tooltip like 'Adjust Song Start Time'
        // to show next
        var nextUnseenTooltipConfig: HooksCaptureTooltip.Configuration? {
            // TODO: Add this back once we have more
            // let tooltips: [(config: HooksCaptureTooltip.Configuration, seen: Bool)] = [
            //     (.adjustSongStartTime, hasSeenAdjustSongTooltip),
            //     (.adjustAudioLevels, hasSeenAdjustAudioTooltip)
            // ]
            if !hasSeenAdjustSongTooltip {
                return .adjustSongStartTime
            }
            return nil
            // TODO: Also add this back once we have more
            // return tooltips.first { !$0.seen }?.config
        }

        var videoPlayerId: String {
            if let trimmedLocalURL,
               let trimmedStartTime,
               let trimmedEndTime
            {
                return "\(trimmedLocalURL.absoluteString)-\(trimmedStartTime)-\(trimmedEndTime)-\(restartVideoTrigger)"
            }
            return "\(capturedLocalURL?.absoluteString ?? "")-\(restartVideoTrigger)"
        }

        // Calculate the actual video duration (trimmed or original)
        var actualVideoDuration: Double {
            if let start = effectiveTrimStart, let end = effectiveTrimEnd {
                return end - start
            }
            return originalEndTime - originalStartTime
        }

        var effectiveLoopDuration: Double {
            return min(actualVideoDuration, snippetEndTime - snippetStartTime)
        }

        // Get current effective trim boundaries (live if trimmer open, saved otherwise)
        var effectiveTrimStart: Double? {
            if case .trimmer(let trimmerState) = inlineEditing {
                return trimmerState.startTimeSeconds
            }
            return trimmedStartTime
        }

        var effectiveTrimEnd: Double? {
            if case .trimmer(let trimmerState) = inlineEditing {
                return trimmerState.endTimeSeconds
            }
            return trimmedEndTime
        }

        var isTrimmerDragging: Bool {
            if case .trimmer(let trimmerState) = inlineEditing {
                return trimmerState.isDragging || trimmerState.isSelectionDragging
            }
            return false
        }

        public init(
            clip: Clip,
            snippetStartTime: Double,
            snippetEndTime: Double,
            initialLocalVideoURL: URL,
            minVideoUploadDuration: TimeInterval,
            maxVideoUploadDuration: TimeInterval
        ) {
            self.clip = clip
            self.snippetStartTime = snippetStartTime
            self.snippetEndTime = snippetEndTime
            self.initialLocalVideoURL = initialLocalVideoURL
            self.minRecordingLimit = minVideoUploadDuration
            self.maxRecordingLimit = maxVideoUploadDuration
        }
    }

    @CasePathable
    public enum Action {
        public enum Internal {
            case nextTapped
            case goToVideoTrimmer
            case goToSnippetSelection
            case setThumbnailImage(UIImage)
            case setVideoPreview(AVAsset, AVPlayer)
        }

        public enum PlaybackScrubbing {
            case beginScrubbing(Double)
            case updateScrubbing(Double)
            case endScrubbing(Double)
        }

        public enum Delegate {
            case didCompleteCaptureAndCompose(
                localPostingAssetVideoURL: URL,
                trimmedStartTime: Double?,
                trimmedEndTime: Double?,
                snippetStartTime: Double,
                snippetEndTime: Double,
                hookThumbnail: UIImage?,
                videoVolumeLevel: Float,
                snippetVolumeLevel: Float
            )
            case videoUploadCompleted(uploadId: String, s3Id: String)
            case dismiss
        }

        @CasePathable
        public enum Editing {
            case startTrimming
            case cancel
            case inline(InlineEditing.Action)
        }

        case task
        case scenePhaseBackground
        case restartSnippet
        case pauseSnippet
        case pauseAll
        case playAll
        case resetAudioVideo
        case didMoveToCamera
        case cameraRecordingDidStart
        case cameraRecordingFinished(localURL: URL, duration: TimeInterval)
        case delegate(Delegate)
        case `internal`(Internal)
        case destination(PresentationAction<Destination.Action>)
        case closeTapped

        // Media picker
        case presentMediaPicker
        case setSelectedMovie(Movie?)
        case setMediaTransferProgress(Double?)
        case mediaPickerDidError
        case dismissMediaPicker
        case hooksPostClientEvent(HooksPostClient.HooksPostClientEvent)
        case videoUploadEvent(HooksVideoUploadClient.VideoUploadEvent)
        case preloadLyricsAndWaveform
        case lyricsEvent(LyricsClientV2.Event)

        // Video Thumbnail
        case generateHookThumbnail(URL)

        // Toast
        case setToast(ToastReducer.State.ToastType?)

        // Tooltip
        case showNextHooksCaptureTooltip
        case dismissCurrentHooksCaptureTooltip

        // Audio selector
        case presentVolumeSelector
        case updateVolumeLevels(videoLevel: Float, snippetLevel: Float)

        // Snippet audio playback sync
        case startSnippetAudioPlayback
        case stopSnippetAudioPlayback
        case videoDidRestart
        case setVideoPlaybackState(Bool)
        case snippetPlayerEvent(SnippetPlayerClientEvent)

        case toggleAudioVideoPlayback

        // Simple snippet selection (when no media)
        case presentSimpleSnippetSelection
        case updateSnippetSelection(startTime: Double, endTime: Double)

        case editing(Editing)
        case playbackScrubbing(PlaybackScrubbing)
    }

    @Dependency(\.apiClientV2) var apiClient
    @Dependency(\.hooksPostClient) var hooksPostClient
    @Dependency(VideoPreviewClient.self) var videoPreviewClient
    @Dependency(\.hooksVideoUploadClient) var videoUploadClient
    @Dependency(\.snippetPlayerClient) var snippetPlayerClient
    @Dependency(LyricsClientV2.self) var lyricsClient
    @Dependency(StatsigClient.self) var statsigClient

    public init() {}
    let thumbnailCount: Int = 1

    private func playSnippetAudio(state: State) {
        let snippet = ClipSnippet(
            clip: state.clip,
            startTime: state.snippetStartTime,
            endTime: state.snippetEndTime
        )
        snippetPlayerClient.setVolume(state.snippetVolumeLevel)
        snippetPlayerClient.loadAndPlaySnippet(snippet)
    }

    public var body: some ReducerOf<Self> {
        Reduce { state, action in
            struct HooksVideoUploadCancellableID: Hashable {}
            struct HooksLyricsEventStreamCancellable: Hashable {}
            struct HooksPostClientEventObserver: Hashable {}
            struct HooksSnippetPlayerEventStreamCancellable: Hashable {}

            switch action {
            case .task:
                let initialLocalVideoURL = state.initialLocalVideoURL
                return .merge(
                    .stream(
                        hooksPostClient.events(),
                        send: Action.hooksPostClientEvent,
                        cancellableId: HooksPostClientEventObserver()
                    ),
                    .stream(
                        snippetPlayerClient.stream(),
                        send: Action.snippetPlayerEvent,
                        cancellableId: HooksSnippetPlayerEventStreamCancellable()
                    ),
                    .send(.preloadLyricsAndWaveform),
                    .run { send in
                        if let duration = await initialLocalVideoURL.videoDuration() {
                            await send(.cameraRecordingFinished(localURL: initialLocalVideoURL, duration: duration))
                        }
                    }
                )

            case .pauseSnippet:
                snippetPlayerClient.pauseCurrentClip()
                return .none

            case .pauseAll:
                snippetPlayerClient.pauseCurrentClip()
                return .send(.setVideoPlaybackState(false))

            case .playAll:
                snippetPlayerClient.playCurrentClip()
                state.isVideoPlaying = true
                return .run { _ in
                    videoPreviewClient.play()
                }

            case .resetAudioVideo:
                state.isVideoMuted = false
                state.hasNavigatedAway = false
                state.restartVideoTrigger += 1
                state.displayTime = 0
                state.hasTriggeredRestartForCurrentLoop = false

                return .merge(
                    .send(.restartSnippet),
                    .run { send in
                        videoPreviewClient.seek(.zero)
                        await send(.setVideoPlaybackState(true))
                    }
                )

            case .restartSnippet:
                let cmTime = CMTime(seconds: state.snippetStartTime, preferredTimescale: 1000)
                snippetPlayerClient.seekTo(cmTime)
                snippetPlayerClient.playCurrentClip()
                state.displayTime = 0
                state.hasTriggeredRestartForCurrentLoop = false
                return .none

            case .toggleAudioVideoPlayback:
                if state.isVideoPlaying {
                    return .send(.pauseAll)
                } else {
                    return .send(.playAll)
                }

            case .playbackScrubbing(let scrubbingAction):
                return playbackScrubbingHandler(scrubbingAction, state: &state)

            case .cameraRecordingDidStart:
                state.isTopNavigationShown = false
                return .send(.restartSnippet)

            case .didMoveToCamera:
                return .none

            case .cameraRecordingFinished(let localURL, let duration):
                state.capturedLocalURL = localURL
                state.capturedDuration = duration
                state.originalStartTime = 0

                state.currentTooltipConfig = nil

                // Calculate the effective video duration (capped at 60 seconds)
                let effectiveVideoDuration = min(duration, state.maxSnippetDuration)
                let currentSnippetDuration = state.snippetEndTime - state.snippetStartTime

                // If video duration is greater than current snippet duration,
                // adjust snippet to accommodate the full video
                if effectiveVideoDuration > currentSnippetDuration {
                    // Try to fit the video at the end of the clip
                    let idealStartTime = state.clip.duration - effectiveVideoDuration

                    if idealStartTime >= 0 {
                        // Video fits at the end of the clip
                        state.snippetStartTime = idealStartTime
                        state.snippetEndTime = state.clip.duration
                    } else {
                        // Video is longer than the entire clip
                        state.snippetStartTime = 0
                        state.snippetEndTime = min(effectiveVideoDuration, state.clip.duration)
                    }
                }

                playSnippetAudio(state: state)
                state.originalEndTime = duration
                state.isTopNavigationShown = true

                // Check duration and set button state accordingly
                let isVideoValid = duration >= state.minRecordingLimit && duration <= state.maxRecordingLimit
                state.actionButtonsEnabled = isVideoValid

                let warningMessage: String
                if duration < state.minRecordingLimit {
                    warningMessage = L10n.FeatureHooks.videoTooShort(Int(state.minRecordingLimit))
                } else {
                    let maxMinutes = Int(state.maxRecordingLimit / 60)
                    warningMessage = L10n.FeatureHooks.videoTooLong(maxMinutes)
                }

                let isMuted = state.isVideoMuted
                let baseEffects: Effect<Action> = .merge(
                    .run { [hookThumbnail = state.hookThumbnail] send in
                        await send(.generateHookThumbnail(localURL))
                        let (asset, player) = try await videoPreviewClient.loadVideo(localURL, isMuted, nil)
                        await send(.internal(.setVideoPreview(asset, player)))
                        if isVideoValid {
                            try await hooksPostClient.uploadVideo(localURL, hookThumbnail)
                        }
                    }
                    .cancellable(id: HooksVideoUploadCancellableID()),
                    .send(.setVideoPlaybackState(true))
                )

                if !isVideoValid {
                    let warningToast = ToastReducer.State.ToastType.warningV2(
                        warningMessage,
                        image: nil,
                        position: .bottom,
                        autoDismiss: false
                    )
                    state.toast = warningToast

                    return baseEffects
                } else {
                    state.toast = nil
                    return .merge(
                        baseEffects,
                        .run { send in
                            try await Task.sleep(for: .seconds(1))
                            await send(.showNextHooksCaptureTooltip)
                        }
                    )
                }

            case .presentMediaPicker:
                state.destination = .mediaPicker
                return .send(.pauseAll)

            case .dismissMediaPicker:
                state.selectedMovie = nil
                state.destination = nil
                return .none

            case .setSelectedMovie(let movie):
                state.selectedMovie = movie
                guard let movie = movie else {
                    return .none
                }
                playSnippetAudio(state: state)
                let isMuted = state.isVideoMuted
                return .run { send in
                    let (asset, player) = try await videoPreviewClient.loadVideo(movie.url, isMuted, nil)
                    await send(.internal(.setVideoPreview(asset, player)))
                }

            case .internal(.nextTapped):
                let localURL = state.trimmedLocalURL ?? state.capturedLocalURL
                guard let localURL else { return .none }

                state.isVideoMuted = true
                state.hasNavigatedAway = true

                return .merge(
                    .send(.pauseAll),
                    .send(.delegate(.didCompleteCaptureAndCompose(
                        localPostingAssetVideoURL: localURL,
                        trimmedStartTime: state.trimmedStartTime ?? state.originalStartTime,
                        trimmedEndTime: state.trimmedEndTime ?? state.originalEndTime,
                        snippetStartTime: state.snippetStartTime,
                        snippetEndTime: state.snippetEndTime,
                        hookThumbnail: state.hookThumbnail,
                        videoVolumeLevel: state.videoVolumeLevel,
                        snippetVolumeLevel: state.snippetVolumeLevel
                    )))
                )

            case .internal(.goToVideoTrimmer):
                return .send(.editing(.startTrimming))

            case .internal(.goToSnippetSelection):
                // Dismiss and set that we've seen this tooltip
                // if it belongs to the current button. Otherwise, just dismiss it.
                if let currentTooltipConfig = state.currentTooltipConfig {
                    state.currentTooltipConfig = nil
                    if case .hasSeenHooksCaptureAdjustSongTooltip = currentTooltipConfig.persistenceKey {
                        state.$hasSeenAdjustSongTooltip.withLock { $0 = true }
                    } else if case .hasSeenHooksCaptureAdjustAudioTooltip = currentTooltipConfig.persistenceKey {
                        state.$hasSeenAdjustAudioTooltip.withLock { $0 = true }
                    }
                }

                // Use trimmed URL if available, otherwise use captured URL
                let videoURL = state.trimmedLocalURL ?? state.capturedLocalURL
                guard videoURL != nil else {
                    return .send(.presentSimpleSnippetSelection)
                }

                // Must have a video with duration to proceed
                guard state.originalEndTime > 0 else { return .none }

                state.inlineEditing = .snippet(.init(
                    startTime: state.snippetStartTime,
                    endTime: state.snippetEndTime
                ))
                return .send(.playAll) // The snippet reducer should never be paused

            case .scenePhaseBackground:
                return .send(.pauseAll)

            case .closeTapped:
                return .run { send in
                    await hooksPostClient.cancelAllTasks()
                    videoPreviewClient.teardown()
                    await send(.delegate(.dismiss))
                }

            case .setMediaTransferProgress(let progress):
                state.mediaTransferProgress = progress ?? 0.0
                return .none

            case .mediaPickerDidError:
                return .none

            case .hooksPostClientEvent(let event):
                return handleHooksPostClientEvent(event, state: &state)

            case .generateHookThumbnail(let url):
                let avAsset = AVURLAsset(url: url)
                return .run { send in
                    do {
                        let thumbnails = try await videoPreviewClient.generateThumbnails(avAsset, thumbnailCount)
                        if let firstThumbnail = thumbnails.first {
                            await send(.internal(.setThumbnailImage(firstThumbnail)))
                        }
                    }
                }

            case .internal(.setThumbnailImage(let image)):
                state.hookThumbnail = image
                return .none

            case .internal(.setVideoPreview(let asset, let player)):
                state.videoPreviewAsset = asset
                state.videoPreviewPlayer = player

                player.isMuted = state.isVideoMuted
                player.volume = state.videoVolumeLevel

                if state.isVideoPlaying {
                    return .run { _ in
                        videoPreviewClient.seek(.zero)
                        videoPreviewClient.play()
                    }
                }
                return .none

            case .preloadLyricsAndWaveform:
                return .stream(
                    lyricsClient.streamForClip(state.clip),
                    send: Action.lyricsEvent,
                    cancellableId: HooksLyricsEventStreamCancellable()
                )

            case .lyricsEvent(let event):
                switch event {
                case .didStartPollingForAlignedLyrics,
                     .didPollClipForAlignedLyrics:
                    break

                case .didSucceedPollingLyrics(let clip, _, let waveformData):
                    if clip.id == state.clip.id {
                        state.waveformDataLoadState = .loaded(waveformData)
                    }

                case .didFailPolling(_, error: let error):
                    log.telemetry.error(error, message: "[Hooks Capture] Lyrics polling failed.")
                    // Use a backup stand-in waveform if polling fails.
                    // NOTE: Maybe this should be a simpler style i.e. alternating bars
                    state.waveformDataLoadState =
                        .loaded(
                            .init(
                                normalizedArray: [],
                                totalDuration: state.clip.duration,
                                emptyWaveformStandInStyle: .noiseySinwave
                            )
                        )
                }
                return .none

            case .setToast(let toast):
                state.toast = toast
                return .none

            case .showNextHooksCaptureTooltip:
                state.currentTooltipConfig = state.nextUnseenTooltipConfig
                return .none

            case .dismissCurrentHooksCaptureTooltip:
                guard let currentConfig = state.currentTooltipConfig else { return .none }

                switch currentConfig.persistenceKey {
                case .hasSeenHooksCaptureAdjustSongTooltip:
                    state.$hasSeenAdjustSongTooltip.withLock { $0 = true }
                case .hasSeenHooksCaptureAdjustAudioTooltip:
                    state.$hasSeenAdjustAudioTooltip.withLock { $0 = true }
                default:
                    break
                }

                state.currentTooltipConfig = nil
                return .none

            case .presentVolumeSelector:
                state.destination = .volumeSelector(
                    HooksVolumeSelector.State(
                        videoVolumeLevel: state.videoVolumeLevel,
                        snippetVolumeLevel: state.snippetVolumeLevel,
                        clip: state.clip
                    )
                )
                return .none

            case .updateVolumeLevels(let videoLevel, let snippetLevel):
                state.videoVolumeLevel = videoLevel
                state.snippetVolumeLevel = snippetLevel
                snippetPlayerClient.setVolume(snippetLevel)
                state.videoPreviewPlayer?.volume = videoLevel
                state.videoPreviewPlayer?.isMuted = state.isVideoMuted || videoLevel == 0
                return .none

            case .startSnippetAudioPlayback:
                let snippet = ClipSnippet(
                    clip: state.clip,
                    startTime: state.snippetStartTime,
                    endTime: state.snippetEndTime
                )
                snippetPlayerClient.setVolume(state.snippetVolumeLevel)
                snippetPlayerClient.loadAndPlaySnippet(snippet)
                return .none

            case .stopSnippetAudioPlayback:
                return .send(.pauseSnippet)

            case .videoDidRestart:
                state.displayTime = 0
                state.hasTriggeredRestartForCurrentLoop = false
                // Seek and play audio immediately to stay in sync with video loop
                snippetPlayerClient.seekTo(CMTime(seconds: state.snippetStartTime, preferredTimescale: 1000))
                snippetPlayerClient.playCurrentClip()
                return .none

            case .setVideoPlaybackState(let isPlaying):
                state.isVideoPlaying = isPlaying
                if isPlaying {
                    playSnippetAudio(state: state)
                    return .run { _ in
                        videoPreviewClient.play()
                    }
                } else {
                    return .merge(
                        .send(.pauseSnippet),
                        .run { _ in
                            videoPreviewClient.pause()
                        }
                    )
                }

            case .destination(.presented(.volumeSelector(.delegate(.volumeLevelsUpdated(let videoLevel, let snippetLevel))))):
                return .send(.updateVolumeLevels(videoLevel: videoLevel, snippetLevel: snippetLevel))

            case .destination(.presented(.volumeSelector(.delegate(.dismiss)))):
                state.destination = nil
                return .none

            case .destination(.presented(.simpleSnippetSelection(.delegate(.cancelTapped)))):
                state.destination = nil
                return .none

            case .destination(.presented(.simpleSnippetSelection(.delegate(.saveTapped)))):
                state.destination = nil
                return .none

            case .destination(.presented(.simpleSnippetSelection(.delegate(.snippetStartTimeChanged(let newStartTime))))):
                // Calculate the end time maintaining the current duration
                let currentDuration = state.snippetEndTime - state.snippetStartTime
                let newEndTime = min(
                    newStartTime + currentDuration,
                    state.clip.duration
                )
                state.snippetStartTime = newStartTime
                state.snippetEndTime = newEndTime
                return .send(.resetAudioVideo)

            case .presentSimpleSnippetSelection:
                state.destination = .simpleSnippetSelection(HookSnippetSelectionReducer.State(
                    duration: state.clip.duration,
                    snippetStartTime: state.snippetStartTime,
                    showCancelButton: false
                ))
                return .none

            case .updateSnippetSelection(let startTime, let endTime):
                state.snippetStartTime = startTime
                state.snippetEndTime = endTime
                state.destination = nil
                return .send(.resetAudioVideo)

            case .videoUploadEvent:
                return .none

            case .snippetPlayerEvent(let event):
                switch event {
                case .snippetChanged:
                    state.hasTriggeredRestartForCurrentLoop = false
                    state.restartVideoTrigger += 1
                    state.displayTime = 0
                    return .merge(
                        .send(.restartSnippet),
                        .run { _ in
                            videoPreviewClient.seek(.zero)
                            videoPreviewClient.play()
                        }
                    )

                case .playbackTimeUpdated(let currentTime):
                    // Don't process playback updates when paused
                    guard state.isVideoPlaying else {
                        return .none
                    }

                    let current = max(state.snippetStartTime, currentTime.seconds)
                    let rel = max(0, current - state.snippetStartTime)

                    let loopWindow = state.effectiveLoopDuration
                    let endTolerance: Double = 0.15
                    let minResetGap: Double = 0.50

                    if !state.isScrubbing && !state.isDraggingWaveform {
                        state.displayTime = min(rel, loopWindow)
                    }

                    // Only trigger loop restart if not actively dragging trimmer
                    if rel >= loopWindow - endTolerance && !state.isTrimmerDragging {
                        if !state.hasTriggeredRestartForCurrentLoop {
                            state.hasTriggeredRestartForCurrentLoop = true

                            // Calculate correct video restart position using live trim boundaries
                            let videoRestartTime: CMTime
                            if let trimmedStart = state.effectiveTrimStart {
                                videoRestartTime = CMTime(seconds: trimmedStart, preferredTimescale: 1000)
                            } else {
                                videoRestartTime = .zero
                            }

                            return .merge(
                                .run { _ in
                                    videoPreviewClient.seek(videoRestartTime)
                                    videoPreviewClient.play()
                                },
                                .send(.videoDidRestart)
                            )
                        }
                    } else if rel < loopWindow - minResetGap {
                        state.hasTriggeredRestartForCurrentLoop = false
                    }
                    return .none

                case .playbackStateChanged:
                    break
                }
                return .none

            case .editing(let editingAction):
                return editingHandler(editingAction, state: &state)

            case .destination,
                 .delegate,
                 .internal:
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)
        .ifLet(\.inlineEditing, action: \.editing.inline) {
            InlineEditing()
        }
        Analytics()
    }

    private func handleHooksPostClientEvent(
        _ event: HooksPostClient.HooksPostClientEvent,
        state _: inout State
    ) -> Effect<Action> {
        switch event {
        case .statusChanged(_, let status, _, _):
            switch status {
            case .uploadingVideo, .uploadVideoSuccessful:
                return .none
            case .uploadVideoFailed:
                // Handle upload failure if needed
                return .none
            default:
                return .none
            }

        case .uploadComplete(_, let uploadId, let s3Id):
            return .send(.delegate(.videoUploadCompleted(uploadId: uploadId, s3Id: s3Id)))

        case .cancelled:
            return .none
        }
    }

    private func playbackScrubbingHandler(
        _ scrubbingAction: Action.PlaybackScrubbing,
        state: inout State
    ) -> Effect<Action> {
        switch scrubbingAction {
        case .beginScrubbing(let relativeSeconds):
            state.isScrubbing = true
            state.displayTime = min(max(0, relativeSeconds), state.effectiveLoopDuration)

            // Video seeks to position within trimmed video (accounting for trim offset)
            let videoSeekTime: CMTime
            if let trimmedStart = state.effectiveTrimStart {
                videoSeekTime = CMTime(seconds: trimmedStart + state.displayTime, preferredTimescale: 1000)
            } else {
                videoSeekTime = CMTime(seconds: state.displayTime, preferredTimescale: 1000)
            }

            return .merge(
                .send(.pauseAll),
                .run { _ in
                    videoPreviewClient.seek(videoSeekTime)
                }
            )

        case .updateScrubbing(let relativeSeconds):
            state.displayTime = min(max(0, relativeSeconds), state.effectiveLoopDuration)

            // Audio seeks to absolute position in song
            let previewTime = CMTime(seconds: state.snippetStartTime + state.displayTime, preferredTimescale: 1000)
            snippetPlayerClient.seekTo(previewTime)

            // Video seeks to position within trimmed video (accounting for trim offset)
            let videoSeekTime: CMTime
            if let trimmedStart = state.effectiveTrimStart {
                videoSeekTime = CMTime(seconds: trimmedStart + state.displayTime, preferredTimescale: 1000)
            } else {
                videoSeekTime = CMTime(seconds: state.displayTime, preferredTimescale: 1000)
            }

            return .run { _ in
                videoPreviewClient.seek(videoSeekTime)
            }

        case .endScrubbing(let relativeSeconds):
            state.displayTime = min(max(0, relativeSeconds), state.effectiveLoopDuration)
            state.isScrubbing = false

            // Seek audio to final position
            let finalTime = CMTime(seconds: state.snippetStartTime + state.displayTime, preferredTimescale: 1000)
            snippetPlayerClient.seekTo(finalTime)

            // Video seeks to position within trimmed video (accounting for trim offset)
            let videoSeekTime: CMTime
            if let trimmedStart = state.effectiveTrimStart {
                videoSeekTime = CMTime(seconds: trimmedStart + state.displayTime, preferredTimescale: 1000)
            } else {
                videoSeekTime = CMTime(seconds: state.displayTime, preferredTimescale: 1000)
            }

            return .merge(
                .run { _ in
                    videoPreviewClient.seek(videoSeekTime)
                },
                .send(.playAll)
            )
        }
    }
}

private extension HooksEditor {
    private func editingHandler(
        _ action: Action.Editing,
        state: inout State
    ) -> Effect<Action> {
        switch action {
        case .startTrimming:
            guard let localURL = state.capturedLocalURL else { return .none }
            state.inlineEditing = .trimmer(.init(
                videoURL: localURL,
                startTime: state.trimmedStartTime ?? state.originalStartTime,
                endTime: state.trimmedEndTime ?? state.originalEndTime,
                duration: state.capturedDuration,
                contentType: .hook,
                minimumDuration: state.minRecordingLimit
            ))
            return .merge(
                .send(.pauseAll),
                .send(.editing(.inline(.trimmer(.loadVideo))))
            )

        case .cancel:
            state.inlineEditing = nil
            return .send(.setVideoPlaybackState(true))

        case .inline(.trimmer(.delegate(.saveResponse(let result)))):
            switch result {
            case .success((let trimmedURL, let trimmedStartTime, let trimmedEndTime)):
                state.trimmedLocalURL = trimmedURL
                state.trimmedStartTime = trimmedStartTime
                state.trimmedEndTime = trimmedEndTime

                // When video is trimmed, update snippet times to match the trimmed duration
                let trimmedDuration = trimmedEndTime - trimmedStartTime
                state.snippetEndTime = min(
                    state.snippetStartTime + state.maxSnippetDuration,
                    state.snippetStartTime + trimmedDuration,
                    state.clip.duration
                )
                state.inlineEditing = nil
                state.isVideoPlaying = true

                let isMuted = state.isVideoMuted
                return .run { send in
                    let (asset, player) = try await videoPreviewClient.loadVideo(trimmedURL, isMuted, nil)
                    await send(.internal(.setVideoPreview(asset, player)))
                    await send(.resetAudioVideo)
                }

            case .failure(let error):
                print(error)
                return .none
            }

        case .inline(.trimmer(.updateCursorPosition)),
                .inline(.trimmer(.dragSelection)):
            // User is dragging trimmer handles/cursor or trimmer frame - pause if playing
            if state.isVideoPlaying {
                return .send(.pauseAll)
            }
            return .none

        case .inline(.trimmer(.handleDragEnd)):
            // Dragging ended - always seek to start of loop for clean playback
            state.hasTriggeredRestartForCurrentLoop = false

            // Always seek audio and video to start when drag ends
            // This ensures clean playback from the beginning of the new trim selection
            let audioSeekTime = CMTime(seconds: state.snippetStartTime, preferredTimescale: 1000)
            let videoSeekTime: CMTime
            if let trimStart = state.effectiveTrimStart {
                videoSeekTime = CMTime(seconds: trimStart, preferredTimescale: 1000)
            } else {
                videoSeekTime = .zero
            }

            state.displayTime = 0

            return .concatenate(
                .run { _ in
                    snippetPlayerClient.seekTo(audioSeekTime)
                    videoPreviewClient.seek(videoSeekTime)
                },
                .send(.playAll)
            )

        case .inline(.trimmer(.dismissTapped)):
            state.inlineEditing = nil
            return .send(.setVideoPlaybackState(true))

        case .inline(.trimmer(.dismiss)):
            state.inlineEditing = nil
            return .send(.setVideoPlaybackState(true))

        case .inline(.snippet(.delegate(.backTapped(let originalSnippetRange)))):
            // Handle time selection change from child reducer
            // Reset since user manually changed selection
            state.hasTriggeredRestartForCurrentLoop = false
            state.restartVideoTrigger += 1

            let startTime = originalSnippetRange.lowerBound
            let endTime = originalSnippetRange.upperBound

            snippetPlayerClient.updateSnippet(
                ClipSnippet(clip: state.clip, startTime: startTime, endTime: endTime),
                startTime,
                endTime,
                true
            )
            let cmTime = CMTime(seconds: startTime, preferredTimescale: 1000)
            snippetPlayerClient.seekTo(cmTime)
            videoPreviewClient.seek(.zero)
            snippetPlayerClient.playCurrentClip()

            state.inlineEditing = nil
            return .none

        case .inline(.snippet(.delegate(.confirmSelectionTapped))):
            state.inlineEditing = nil
            return .none

        case .inline(.snippet(.delegate(.waveformDragStarted))):
            state.isDraggingWaveform = true
            return .none

        case .inline(.snippet(.delegate(.waveformDragUpdated(let timeRange)))):
            state.snippetStartTime = timeRange.lowerBound
            state.snippetEndTime = timeRange.upperBound
            return .none

        case .inline(.snippet(.delegate(.waveformDragEnded(let timeRange)))),
             .inline(.snippet(.delegate(.timeSelectionChanged(let timeRange)))):
            let startTime = timeRange.lowerBound
            let endTime = timeRange.upperBound

            state.snippetStartTime = startTime
            state.snippetEndTime = endTime

            snippetPlayerClient.updateSnippet(
                ClipSnippet(clip: state.clip, startTime: startTime, endTime: endTime),
                startTime,
                endTime,
                true
            )
            // Seek
            // Note: Pausing before the seek here seems to cause jank w/ the video playback
            let cmTime = CMTime(seconds: startTime, preferredTimescale: 1000)
            snippetPlayerClient.seekTo(cmTime)
            videoPreviewClient.seek(.zero)
            // Play
            snippetPlayerClient.playCurrentClip()
            videoPreviewClient.play()

            // Clean up state
            // Handle time selection change from child reducer
            // Reset since user manually changed selection
            state.hasTriggeredRestartForCurrentLoop = false
            state.restartVideoTrigger += 1
            state.displayTime = 0
            state.isDraggingWaveform = false
            return .none

        default:
            return .none
        }
    }
}

// MARK: - EditingMode

private enum EditingMode: Equatable {
    case idle
    case trimming(StoreOf<VideoTrimmerV2>)
    case snippet(StoreOf<HooksEditorSnippetSelectionReducer>)

    var allowsTapGesture: Bool {
        switch self {
        case .idle, .trimming:
            return true
        case .snippet:
            return false
        }
    }
}

private extension EditingMode {
    var trimmingStore: StoreOf<VideoTrimmerV2>? {
        if case .trimming(let store) = self {
            return store
        }
        return nil
    }

    var snippetStore: StoreOf<HooksEditorSnippetSelectionReducer>? {
        if case .snippet(let store) = self {
            return store
        }
        return nil
    }

    static func fromHooksEditor(
        store: StoreOf<HooksEditor>
    ) -> Self {
        if let store = store.scope(
            state: \.inlineEditing?.trimmer,
            action: \.editing.inline.trimmer
        ) {
            return .trimming(store)
        }

        if let store = store.scope(
            state: \.inlineEditing?.snippet,
            action: \.editing.inline.snippet
        ) {
            return .snippet(store)
        }

        return .idle
    }
}

private extension EditingMode {
    var isEditing: Bool {
        switch self {
        case .idle: false
        case .trimming, .snippet: true
        }
    }

    var cornerRadius: CGFloat {
        switch self {
        case .idle: HooksEditorUI.Preview.loopingCornerRadius
        case .trimming, .snippet: HooksEditorUI.Preview.editingCornerRadius
        }
    }

    // If a mode provides a custom player, return it
    // Otherwise caller uses store.videoPreviewPlayer
    var playerOverride: AVPlayer? {
        switch self {
        case .trimming(let store): store.player
        case .snippet, .idle: nil
        }
    }

    func targetPreviewSize(idleWidth: CGFloat) -> CGSize {
        switch self {
        case .idle:
            .init(width: idleWidth, height: idleWidth * HooksEditorUI.Preview.aspect)
        case .trimming:
            HooksEditorUI.Preview.trimmerSize
        case .snippet:
            HooksEditorUI.Preview.snippetSize
        }
    }
}

// MARK: - UI Constants

private enum HooksEditorUI {
    enum Metrics {
        static let topPadding: CGFloat = 12
        static let hPadding: CGFloat = 12

        static let idleBottomAreaHeight: CGFloat = 64
        static let idleBottomContentHeight: CGFloat = idleBottomAreaHeight - 20
        static let idleBottomPadding: CGFloat = 44

        static let inlineTopPaddingFromSafeArea: CGFloat = topPadding
        static let inlineHorizontalPadding: CGFloat = hPadding

        static let spacingTopToPreview: CGFloat = hPadding
        static let spacingPreviewToBottom: CGFloat = hPadding

        static let editingPreviewOverlayPadding: CGFloat = hPadding / 2

        static let trimmerHeight: CGFloat = 60
        static let trimmerHandleHeight: CGFloat = 72

        static let topNavButtonSize: CGFloat = 40
        static let topNavButtonIconSize: CGFloat = 20
        static let topNavArrowLetIconSize: CGFloat = 32
    }

    enum Preview {
        static let aspect: CGFloat = 16.0 / 9.0

        static let loopingCornerRadius: CGFloat = 16
        static let editingCornerRadius: CGFloat = 20

        static var trimmerWidth: CGFloat {
            UIScreen.width - HooksEditorUI.Metrics.hPadding * 8
        }

        static var trimmerSize: CGSize {
            .init(width: trimmerWidth, height: trimmerWidth * aspect)
        }

        static var snippetSize: CGSize { trimmerSize }
    }

    enum Priority {
        static let topControls: Double = 1
        static let bottomControls: Double = topControls

        // Always shrink preview to fit controls if needed
        static let videoPreview: Double = min(topControls, bottomControls) - 1
    }

    enum Snippet {
        static let waveformWidth: CGFloat = UIScreen.width * 0.75
        static let waveformHPadding: CGFloat = (UIScreen.width - waveformWidth) / 2
    }
}

// MARK: - PostCaptureLayout

private struct PostCaptureLayout {
    let spacingTopToPreview: CGFloat = HooksEditorUI.Metrics.spacingTopToPreview
    let spacingPreviewToBottom: CGFloat = HooksEditorUI.Metrics.spacingPreviewToBottom
    let editingPreviewOverlayPadding: CGFloat = HooksEditorUI.Metrics.editingPreviewOverlayPadding

    let idleBottomAreaHeight: CGFloat = HooksEditorUI.Metrics.idleBottomAreaHeight
    let idleBottomContentHeight: CGFloat = HooksEditorUI.Metrics.idleBottomContentHeight
    let idleBottomPadding: CGFloat = HooksEditorUI.Metrics.idleBottomPadding

    let inlineTopPaddingFromSafeArea: CGFloat = HooksEditorUI.Metrics.inlineTopPaddingFromSafeArea
    let inlineHorizontalPadding: CGFloat = HooksEditorUI.Metrics.inlineHorizontalPadding

    let trimmerHeight: CGFloat = HooksEditorUI.Metrics.trimmerHeight
    let trimmerHandleHeight: CGFloat = HooksEditorUI.Metrics.trimmerHandleHeight
}

// MARK: - Animation

private extension Animation {
    static var editingTransitionAnimation: Self {
        .spring(response: 0.45, dampingFraction: 0.9, blendDuration: 0.25)
    }
}

// MARK: - HooksEditorView

public struct HooksEditorView: View {
    private enum Constants {
        static let loopingVideoPlayerCornerRadius: CGFloat = 16
        static let overlayEdgeHorizontalPadding: CGFloat = 16
    }

    @Bindable var store: StoreOf<HooksEditor>
    @Environment(\.scenePhase) private var scenePhase
    @Environment(\.safeAreaInsets) private var safeAreaInsets

    public init(store: StoreOf<HooksEditor>) {
        self.store = store
    }

    public var body: some View {
        content
            .HooksEditorSheets(store: store)
            .onChange(of: scenePhase) { _, new in
                if new == .background {
                    store.send(.scenePhaseBackground)
                }
            }
            .task {
                store.send(.task)
            }
            .onAppear {
                if store.hasNavigatedAway {
                    store.send(.resetAudioVideo)
                }
            }
            .animation(.editingTransitionAnimation, value: store.isInlineEditingActive)
            .toast(
                $store.toast.sending(\.setToast),
                position: .bottom,
                padding: .init(top: 0, leading: 0, bottom: safeAreaInsets.bottom + 10, trailing: 0)
            )
            .preferredColorScheme(.dark)
    }

    @ViewBuilder
    private var content: some View {
        if store.isPostCapture {
            postCaptureContent
        } else {
            GradientSpinner(size: .large,
                            startColor: Color.SemanticV2.backgroundDarkOverlay,
                            endColor: Color.SemanticV2.foregroundPrimary)
                .frame(maxHeight: .infinity)
        }
    }

    private var postCaptureContent: some View {
        let mode = EditingMode.fromHooksEditor(store: store)
        let layout = PostCaptureLayout()

        let screenWidth = UIScreen.width
        let screenHeight = UIScreen.height
        let topSafeAreaInsets = safeAreaInsets.top

        let idleWidth = screenWidth
        let targetPreviewSize = mode.targetPreviewSize(idleWidth: idleWidth)
        let cornerRadius = mode.cornerRadius

        let showActionOverlays = mode == .idle
        let previewPlayer = mode.playerOverride ?? store.videoPreviewPlayer

        return VStack(spacing: 0) {
            HStack {
                if mode.isEditing {
                    topBarRow(for: mode)
                        .padding(.top, layout.inlineTopPaddingFromSafeArea + topSafeAreaInsets)
                        .padding(.horizontal, layout.inlineHorizontalPadding)
                        .layoutPriority(HooksEditorUI.Priority.topControls)
                }
            }
            .frame(width: screenWidth)

            Spacer()
                .modifier(if: mode.isEditing) { view in
                    view
                        .frame(height: layout.spacingTopToPreview)
                        .layoutPriority(HooksEditorUI.Priority.topControls)
                }
                .modifier(if: !mode.isEditing) { view in
                    view
                        .frame(height: topSafeAreaInsets)
                }

            loopingVideoPlayer(
                player: previewPlayer,
                allowTapGesture: mode.allowsTapGesture,
                showActionOverlays: showActionOverlays,
                videoPreviewWidth: targetPreviewSize.width,
                mode: mode
            )
            .frame(width: targetPreviewSize.width, height: targetPreviewSize.height)
            .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
            .topBarIdleBackgroundAndBorder(
                RoundedRectangle(cornerRadius: cornerRadius),
                borderWidth: 0.5
            )
            .overlay(alignment: .bottom) {
                bottomPreviewOverlay(
                    for: mode,
                    layout: layout
                )
                .transition(.move(edge: .bottom).combined(with: .opacity))
            }
            .layoutPriority(HooksEditorUI.Priority.videoPreview)

            VStack(alignment: .center) {
                Spacer()

                bottomControls(
                    for: mode,
                    layout: layout,
                    screenWidth: screenWidth
                )
                .layoutPriority(HooksEditorUI.Priority.bottomControls)

                Spacer()
            }
            .frame(maxHeight: .infinity)

            Spacer()
                .frame(height: safeAreaInsets.bottom)
        }
        .frame(height: screenHeight)
    }
}

private extension HooksEditorView {
    @ViewBuilder
    func topBarRow(
        for mode: EditingMode
    ) -> some View {
        switch mode {
        case .idle:
            EmptyView()

        case .trimming(let store):
            trimmerToolbar(store: store)

        case .snippet(let store):
            snippetToolbar(store: store)
        }
    }

    @ViewBuilder
    func bottomPreviewOverlay(
        for mode: EditingMode,
        layout: PostCaptureLayout
    ) -> some View {
        switch mode {
        case .idle, .snippet:
            EmptyView()

        case .trimming(let store):
            trimmerDurationPill(store: store)
                .editingBottomControlsOverlayBackground()
                .padding(layout.editingPreviewOverlayPadding)
        }
    }

    @ViewBuilder
    func bottomControls(
        for mode: EditingMode,
        layout: PostCaptureLayout,
        screenWidth: CGFloat
    ) -> some View {
        switch mode {
        case .idle:
            nextButton(
                contentHeight: layout.idleBottomContentHeight
            )
            .transition(.move(edge: .bottom).combined(with: .opacity))
            .animation(.editingTransitionAnimation, value: mode)

        case .trimming(let store):
            trimmerBottomControls(
                store: store,
                screenWidth: screenWidth,
                timerControlsHorizontalPadding: layout.inlineHorizontalPadding * 2,
                trimmerHeight: layout.trimmerHeight,
                trimmerHandleHeight: layout.trimmerHandleHeight,
                showThumbnailStrip: store.showThumbnailStrip
            )
            .animation(.editingTransitionAnimation, value: store.showThumbnailStrip)

        case .snippet(let snippetSelectionStore):
            snippetSelectionBottomControls(snippetSelectionStore: snippetSelectionStore)
                .animation(.editingTransitionAnimation, value: store.waveformDataLoadState)
        }
    }
}

private extension HooksEditorView {
    @ViewBuilder
    private func trimmerBottomControls(
        store: StoreOf<VideoTrimmerV2>,
        screenWidth: CGFloat,
        timerControlsHorizontalPadding: CGFloat,
        trimmerHeight: CGFloat,
        trimmerHandleHeight: CGFloat,
        showThumbnailStrip: Bool
    ) -> some View {
        if showThumbnailStrip {
            trimmerControls(
                store: store,
                trimmerHeight: trimmerHeight,
                trimmerHandleHeight: trimmerHandleHeight
            )
            .frame(
                width: screenWidth - (2 * timerControlsHorizontalPadding),
                height: max(trimmerHeight, trimmerHandleHeight)
            )
            .transition(.move(edge: .bottom).combined(with: .opacity))
        } else {
            GradientSpinner(size: .large,
                            startColor: Color.SemanticV2.backgroundDarkOverlay,
                            endColor: Color.SemanticV2.foregroundPrimary)
                .frame(height: max(trimmerHeight, trimmerHandleHeight))
                .transition(.opacity)
        }
    }

    @ViewBuilder
    private func snippetSelectionBottomControls(
        snippetSelectionStore: StoreOf<HooksEditorSnippetSelectionReducer>
    ) -> some View {
        if store.waveformDataLoadState == .loading {
            GradientSpinner(size: .large,
                            startColor: Color.SemanticV2.backgroundDarkOverlay,
                            endColor: Color.SemanticV2.foregroundPrimary)
                .transition(.opacity)
        } else {
            VStack(spacing: 16) {
                SelectedClipTimeView(
                    startTime: store.snippetStartTime,
                    endTime: store.snippetEndTime,
                    maxDuration: HooksConstants.maxHookMediaDuration,
                    timeSelectedText: L10n.FeatureHooks.timeSelected
                )

                snippetWaveform(snippetSelectionStore: snippetSelectionStore)
            }
            .transition(.move(edge: .bottom).combined(with: .opacity))
        }
    }
}

private extension View {
    func editingBottomControlsOverlayBackground() -> some View {
        self.modifier(EditingBottomControlsOverlayBackground())
    }
}

private struct EditingBottomControlsOverlayBackground: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding(.horizontal, 16)
            .padding(.vertical, 12)
            .background {
                Capsule()
                    .fill(.ultraThinMaterial)
                    .strokeBorder(Color.SemanticV1.alwaysLighterBeige.opacity(0.1), lineWidth: 0.5)
            }
    }
}

// MARK: - Preview Bottom Overlay UI

private extension HooksEditorView {
    func trimmerDurationPill(
        store: StoreOf<VideoTrimmerV2>
    ) -> some View {
        let total = (store.rightHandlePosition - store.leftHandlePosition) * store.videoDuration
        let formattedDuration = "\(Int(total))s"

        return Text(formattedDuration)
            .foregroundColor(.white)
            .typographyV1(.monospace.size { _ in 14.0 }.lineHeight(22.0))
            .transition(.scale(scale: 0.9).combined(with: .opacity))
            .animation(.easeInOut(duration: 0.3), value: store.isDragging)
    }
}

// MARK: - Video Trimmer UI

private extension HooksEditorView {
    @ViewBuilder
    func trimmerToolbar(
        store: StoreOf<VideoTrimmerV2>
    ) -> some View {
        TopBarButtonControl(
            icon: Image.Icon.close,
            foregroundColor: .SemanticV2.foregroundPrimary,
            backgroundColor: .SemanticV2.backgroundSecondary,
            iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
            buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
            action: { store.send(.dismissTapped) }
        )
        .transition(.move(edge: .leading).combined(with: .opacity))

        HStack {
            Spacer()

            Text(L10n.FeatureHooks.trimVideo)
                .typographyV1(.headline4.size { _ in 16.0 }.lineHeight(22.0))
                .foregroundStyle(Color.SemanticV2.foregroundPrimary)

            Spacer()
        }
        .transition(.move(edge: .top).combined(with: .opacity))

        TopBarButtonControl(
            icon: Image.Icon.checkV1,
            foregroundColor: .SemanticV1.backgroundPrimary,
            backgroundColor: .SemanticV1.textPrimary,
            iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
            buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
            showIcon: !store.isExporting,
            action: { store.send(.save) },
            additionalContent: {
                ProgressView()
                    .opacity(store.isExporting ? 1 : 0)
                    .frame(
                        width: HooksEditorUI.Metrics.topNavButtonIconSize,
                        height: HooksEditorUI.Metrics.topNavButtonIconSize
                    )
                    .tint(Color.SemanticV2.backgroundPrimary)
            }
        )
        .transition(.move(edge: .trailing).combined(with: .opacity))
    }

    func trimmerControls(
        store: StoreOf<VideoTrimmerV2>,
        trimmerHeight: CGFloat,
        trimmerHandleHeight: CGFloat
    ) -> some View {
        ZStack(alignment: .leading) {
            ThumbnailStrip(
                thumbnails: store.thumbnails,
                width: store.bottomStripWidth,
                start: store.leftHandlePosition,
                end: store.rightHandlePosition,
                height: trimmerHeight
            )

            TrimmerHandlesView(
                store: store,
                height: trimmerHeight
            )

            TrimmerPlaybackCursorView(
                store: store,
                height: trimmerHandleHeight
            )
        }
    }
}

// MARK: - Snippet Selection UI

private extension HooksEditorView {
    @ViewBuilder
    func snippetToolbar(
        store: StoreOf<HooksEditorSnippetSelectionReducer>
    ) -> some View {
        TopBarButtonControl(
            icon: Image.Icon.close,
            foregroundColor: .SemanticV2.foregroundPrimary,
            backgroundColor: .SemanticV2.backgroundSecondary,
            iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
            buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
            action: {
                store
                    .send(.delegate(.backTapped(originalSnippetRange: store.initialStartTime ... store.initialEndTime)))
            }
        )
        .transition(.move(edge: .leading).combined(with: .opacity))

        HStack {
            Spacer()

            Text(L10n.FeatureHooks.adjustSong)
                .typographyV1(.headline4.size { _ in 16.0 }.lineHeight(22.0))

            Spacer()
        }
        .transition(.move(edge: .top).combined(with: .opacity))

        TopBarButtonControl(
            icon: Image.Icon.checkV1,
            foregroundColor: .SemanticV1.backgroundPrimary,
            backgroundColor: .SemanticV1.textPrimary,
            iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
            buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
            action: { store.send(.delegate(.confirmSelectionTapped)) }
        )
        .transition(.move(edge: .trailing).combined(with: .opacity))
    }

    func snippetWaveform(
        snippetSelectionStore: StoreOf<HooksEditorSnippetSelectionReducer>
    ) -> some View {
        let config = SlideWindowWaveformView.Config(
            constantFrameWidth: HooksEditorUI.Snippet.waveformWidth,
            windowDuration: snippetSelectionStore.windowDuration,
            waveformSource: store.waveformDataLoadState.value ?? .empty,
            waveformFrameColor: .SemanticV1.textPrimary
        )

        func onWaveformSlideUpdate(_ update: SlideWindowWaveformView.Update) {
            switch update {
            case .onDragStart:
                snippetSelectionStore.send(.delegate(.waveformDragStarted))

            case .onDragChanged(let timeRange):
                snippetSelectionStore.send(.delegate(.waveformDragUpdated(timeRange)))

            case .onDragEnd(let timeRange):
                snippetSelectionStore.send(.delegate(.waveformDragEnded(timeRange)))

            case .onWindowUpdate(let timeRange):
                snippetSelectionStore.send(.delegate(.timeSelectionChanged(timeRange)))
            }
        }

        let vPadding: CGFloat = 4.0
        let halfVPadding: CGFloat = vPadding / 2.0
        let hPadding: CGFloat = HooksEditorUI.Snippet.waveformHPadding
        let frameHeight: CGFloat = 47.0

        return SlideWindowWaveformView(
            config: config,
            onSlideUpdate: onWaveformSlideUpdate,
            progress: store.progress,
            startTime: snippetSelectionStore.initialStartTime
        )
        .frame(width: HooksEditorUI.Snippet.waveformWidth, height: frameHeight)
        .padding(.horizontal, hPadding)
        .padding(.vertical, halfVPadding)
        .frame(height: frameHeight + vPadding)
        .clipped()
        .allowsHitTesting(true)
    }
}

// MARK: - Post-Capture Idle UI

private extension HooksEditorView {
    func nextButton(
        contentHeight: CGFloat
    ) -> some View {
        Button {
            store.send(.internal(.nextTapped))
        } label: {
            HStack {
                Text(L10n.FeatureBrandedAlert.next)
                    .typographyV1(.button1)
                    .foregroundStyle(Color.SemanticV2.backgroundPrimary)
                Image.Icon.arrowRightV2
                    .resizable()
                    .frame(width: 24.0, height: 24.0)
                    .foregroundStyle(Color.SemanticV2.backgroundPrimary)
            }
            .padding(.horizontal, 16.0)
            .frame(height: contentHeight)
            .background {
                Color.SemanticV2.foregroundPrimary
            }
            .clipShape(.rect(cornerRadius: .infinity))
        }
        .disabled(!store.actionButtonsEnabled || store.isScrubbing)
        .opacity((store.actionButtonsEnabled && !store.isScrubbing) ? 1.0 : 0.5)
    }

    func loopingVideoPlayer(
        player: AVPlayer?,
        allowTapGesture: Bool,
        showActionOverlays: Bool,
        videoPreviewWidth: CGFloat,
        mode: EditingMode
    ) -> some View {
        ZStack {
            VideoPreviewView(player: player ?? store.videoPreviewPlayer, resizeMode: .resizeAspectFill)
                .background(Color.black)
                .onTapGesture {
                    guard allowTapGesture else { return }
                    store.send(.toggleAudioVideoPlayback)
                }

            if !store.isVideoPlaying {
                Image.Icon.playFilled
                    .resizable()
                    .frame(width: 48, height: 48)
                    .foregroundColor(Color.SemanticV2.foregroundPrimary)
                    .transition(.opacity)
                    .allowsHitTesting(false)
            }

            overlayForPostCaptureLoopingVideoPlayer(
                showActionOverlays: showActionOverlays,
                videoPreviewWidth: videoPreviewWidth
            )
            .frame(width: videoPreviewWidth)
        }
        .background {
            Color.SemanticV1.backgroundTertiary
        }
        .clipShape(.rect(cornerRadius: HooksEditorUI.Preview.loopingCornerRadius))
        .id(store.videoPlayerId)
    }

    func overlayForPostCaptureLoopingVideoPlayer(
        showActionOverlays: Bool,
        videoPreviewWidth: CGFloat
    ) -> some View {
        let elementBorderWidth: CGFloat = 1.0
        let buttonBackgroundColor = Color.clear
        let topRowSpacingAndEdgePadding = HooksEditorUI.Metrics.hPadding

        let compressRelativeToSiblingsPriority = -1.0

        return VStack {
            ZStack {
                if showActionOverlays {
                    topBarRowPostCaptureIdlePreview(
                        elementBorderWidth: elementBorderWidth,
                        videoPreviewWidth: videoPreviewWidth,
                        buttonBackgroundColor: buttonBackgroundColor,
                        firstElementLeadingPadding: topRowSpacingAndEdgePadding,
                        lastElementTrailingPadding: topRowSpacingAndEdgePadding
                    )
                    .padding(.top, HooksEditorUI.Metrics.topPadding)
                }
            }

            Spacer()

            HStack {
                Spacer()

                if showActionOverlays {
                    rightRailPostCaptureIdlePreview(
                        borderWidth: elementBorderWidth,
                        buttonBackgroundColor: buttonBackgroundColor
                    )
                }
            }
            .modifier(if: showActionOverlays) { view in
                view
                    .padding(.horizontal, HooksEditorUI.Metrics.hPadding)
                    .transition(.move(edge: .trailing).combined(with: .opacity))
            }
            .frame(maxHeight: .infinity)

            Spacer()

            playbackScrubberForLoopingVideoPlayer
                .layoutPriority(compressRelativeToSiblingsPriority)
                .opacity(showActionOverlays ? 1 : 0)
                .transition(.opacity)
        }
    }

    var playbackScrubberForLoopingVideoPlayer: some View {
        HooksEditorScrubBarView(
            totalDuration: store.effectiveLoopDuration,
            currentTime: store.displayTime,
            isScrubbing: store.isScrubbing,
            horizontalPadding: Constants.overlayEdgeHorizontalPadding,
            backgroundCornerRadius: Constants.loopingVideoPlayerCornerRadius,
            onScrubStart: { seconds in
                store.send(.playbackScrubbing(.beginScrubbing(seconds)))
            },
            onScrubChange: { seconds in
                store.send(.playbackScrubbing(.updateScrubbing(seconds)))
            },
            onScrubEnd: { seconds in
                store.send(.playbackScrubbing(.endScrubbing(seconds)))
            }
        )
    }

    @ViewBuilder
    private func topBarRowPostCaptureIdlePreview(
        elementBorderWidth: CGFloat,
        videoPreviewWidth: CGFloat,
        buttonBackgroundColor: Color,
        firstElementLeadingPadding: CGFloat,
        lastElementTrailingPadding _: CGFloat
    ) -> some View {
        songPillPostCaptureIdlePreview(
            borderWidth: elementBorderWidth,
            previewWidth: videoPreviewWidth
        )
        .disabled(!store.actionButtonsEnabled)
        .opacity(store.actionButtonsEnabled ? 1.0 : 0.5)
        .frame(maxWidth: .infinity)
        .transition(.move(edge: .top).combined(with: .opacity))
        .maybeHooksCaptureTooltip(
            configuration: store.currentTooltipConfig,
            onDismiss: { store.send(.dismissCurrentHooksCaptureTooltip) }
        )

        topLeadingActionButtonPostCaptureIdlePreview(
            borderWidth: elementBorderWidth,
            backgroundColor: buttonBackgroundColor,
            isFirstScreen: store.isFirstScreen
        )
        .padding(.leading, firstElementLeadingPadding)
        .frame(maxWidth: .infinity, alignment: .leading)
        .transition(.move(edge: .leading).combined(with: .opacity))
    }

    private func topLeadingActionButtonPostCaptureIdlePreview(
        borderWidth: CGFloat,
        backgroundColor: Color,
        isFirstScreen: Bool
    ) -> some View {
        TopBarButtonControl(
            icon: isFirstScreen ? Image.Icon.close : Image.Icon.arrowLeft,
            foregroundColor: .SemanticV1.textPrimary,
            backgroundColor: backgroundColor,
            iconSize: HooksEditorUI.Metrics.topNavArrowLetIconSize,
            buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
            borderWidth: borderWidth,
            action: { store.send(.closeTapped) }
        )
        .topBarIdleBackgroundAndBorder(Circle(), borderWidth: borderWidth)
    }

    private func rightRailPostCaptureIdlePreview(
        borderWidth: CGFloat,
        buttonBackgroundColor: Color
    ) -> some View {
        VStack(spacing: 10) {
            TopBarButtonControl(
                icon: Image.Icon.scissors,
                foregroundColor: .SemanticV1.textPrimary,
                backgroundColor: buttonBackgroundColor,
                iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
                buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
                borderWidth: borderWidth,
                action: { store.send(.editing(.startTrimming)) }
            )
            .topBarIdleBackgroundAndBorder(Circle(), borderWidth: borderWidth)
            .disabled(!store.actionButtonsEnabled)
            .opacity(store.actionButtonsEnabled ? 1.0 : 0.5)

            TopBarButtonControl(
                icon: Image.Icon.volumeOn,
                foregroundColor: .SemanticV1.textPrimary,
                backgroundColor: buttonBackgroundColor,
                iconSize: HooksEditorUI.Metrics.topNavButtonIconSize,
                buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
                borderWidth: borderWidth,
                action: { store.send(.presentVolumeSelector) }
            )
            .topBarIdleBackgroundAndBorder(Circle(), borderWidth: borderWidth)
            .disabled(!store.actionButtonsEnabled)
            .opacity(store.actionButtonsEnabled ? 1.0 : 0.5)

            TopBarButtonControl(
                icon: Image.Icon.galleryV2,
                foregroundColor: .SemanticV1.textPrimary,
                backgroundColor: buttonBackgroundColor,
                iconSize: 32,
                buttonSize: HooksEditorUI.Metrics.topNavButtonSize,
                borderWidth: borderWidth,
                action: { store.send(.presentMediaPicker) }
            )
            .topBarIdleBackgroundAndBorder(Circle(), borderWidth: borderWidth)
        }
    }

    private func songPillPostCaptureIdlePreview(
        borderWidth: CGFloat,
        previewWidth: CGFloat
    ) -> some View {
        let cornerRadius: CGFloat = 16

        let containerLeadingSpacing: CGFloat = 8
        let containerTrailingSpacing: CGFloat = 12
        let stackSpacing: CGFloat = 8
        let songImageWidth: CGFloat = 26

        let marqueeStackSpacing: CGFloat = 4
        let trailingIconImageWidth: CGFloat = 12

        let maxTitleWidthToPreviewWidthRatio = 4.0 / 7.0
        let maxTitleWidth = previewWidth * maxTitleWidthToPreviewWidthRatio - songImageWidth - trailingIconImageWidth

        let marqueeFont = TypographyV1.caption.uiFont ?? .systemFont(ofSize: 12)

        return Button(
            action: { store.send(.internal(.goToSnippetSelection)) },
            label: {
                HStack(spacing: stackSpacing) {
                    RemoteImage(
                        url: store.clip.imageUrl,
                        fallbackId: "1"
                    )
                    .frame(width: songImageWidth, height: 30)
                    .clipShape(.rect(cornerRadius: 8))

                    HStack(spacing: marqueeStackSpacing) {
                        MarqueeText(
                            text: store.clip.title,
                            font: marqueeFont,
                            leftFade: 4,
                            rightFade: 4,
                            startDelay: 1
                        )
                        .foregroundStyle(Color.SemanticV1.alwaysLighterBeige)
                        .frame(maxWidth: min(store.clip.title.boundingBox(with: marqueeFont).width, maxTitleWidth))
                        .id("song-pill-title-\(store.clip.id.remoteId)")

                        Image.Icon.chevronDown
                            .resizable()
                            .frame(width: trailingIconImageWidth, height: 12)
                            .foregroundStyle(Color.SemanticV2.foregroundTertiaryGlass)
                    }
                }
                .padding([.vertical, .leading], containerLeadingSpacing)
                .padding(.trailing, containerTrailingSpacing)
                .clipShape(.rect(cornerRadius: cornerRadius))
                .topBarIdleBackgroundAndBorder(RoundedRectangle(cornerRadius: cornerRadius), borderWidth: borderWidth)
            }
        )
    }

    private func trailingEdgeActionButton(
        icon: Image,
        enabled: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            icon
                .resizable()
                .frame(width: 24, height: 24)
                .foregroundStyle(Color.SemanticV1.iconOnDark)
                .padding(8)
        }
        .background(Color.black.opacity(0.2))
        .clipShape(.circle)
        .buttonStyle(.plain)
        .disabled(!enabled)
        .opacity(enabled ? 1.0 : 0.5)
    }
}

// MARK: - TopBarIdleBorder

private extension View {
    func topBarIdleBackgroundAndBorder<S: Shape>(_ shape: S, borderWidth: CGFloat) -> some View {
        self.modifier(TopBarIdleBackgroundAndBorder(shape: shape, borderWidth: borderWidth))
    }
}

private struct TopBarIdleBackgroundAndBorder<S: Shape>: ViewModifier {
    let shape: S
    let borderWidth: CGFloat

    func body(content: Content) -> some View {
        content
            .background {
                shape
                    .fill(.ultraThinMaterial)
                    .overlay(
                        shape
                            .stroke(
                                Color.SemanticV2.backgroundGlassDense,
                                lineWidth: 0.5,
                                antialiased: false
                            )
                    )
            }
    }
}

// MARK: - Helper Views

private struct TopBarButtonControl<AdditionalContent: View>: View {
    private let icon: Image
    private let foregroundColor: Color
    private let backgroundColor: Color
    private let action: () -> Void
    private let iconSize: CGFloat
    private let buttonSize: CGFloat
    private let showIcon: Bool
    private let borderWidth: CGFloat?
    @ViewBuilder private var additionalContent: () -> AdditionalContent

    init(
        icon: Image,
        foregroundColor: Color,
        backgroundColor: Color,
        iconSize: CGFloat,
        buttonSize: CGFloat,
        showIcon: Bool = true,
        borderWidth: CGFloat? = nil,
        action: @escaping () -> Void,
        @ViewBuilder additionalContent: @escaping () -> AdditionalContent
    ) {
        self.icon = icon
        self.foregroundColor = foregroundColor
        self.backgroundColor = backgroundColor
        self.action = action
        self.iconSize = iconSize
        self.buttonSize = buttonSize
        self.showIcon = showIcon
        self.borderWidth = borderWidth
        self.additionalContent = additionalContent
    }

    init(
        icon: Image,
        foregroundColor: Color,
        backgroundColor: Color,
        iconSize: CGFloat,
        buttonSize: CGFloat,
        showIcon: Bool = true,
        borderWidth: CGFloat? = nil,
        action: @escaping () -> Void
    ) where AdditionalContent == EmptyView {
        self.init(
            icon: icon,
            foregroundColor: foregroundColor,
            backgroundColor: backgroundColor,
            iconSize: iconSize,
            buttonSize: buttonSize,
            showIcon: showIcon,
            borderWidth: borderWidth,
            action: action,
            additionalContent: { EmptyView() }
        )
    }

    private var borderAdjustedSize: CGFloat {
        if let borderWidth {
            return buttonSize - borderWidth
        }
        return buttonSize
    }

    var body: some View {
        Button(action: action) {
            ZStack {
                icon
                    .resizable()
                    .foregroundColor(foregroundColor)
                    .frame(width: iconSize, height: iconSize)
                    .opacity(showIcon ? 1 : 0)
                    .animation(.easeInOut, value: showIcon)

                additionalContent()
            }
        }
        .frame(width: borderAdjustedSize, height: borderAdjustedSize)
        .contentShape(Circle())
        .background(Circle().fill(backgroundColor))
        .buttonStyle(ScaleButtonStyle(scaleAmount: 0.98))
    }
}

private struct TrimmerHandlesView: View {
    @Bindable var store: StoreOf<VideoTrimmerV2>
    let height: CGFloat

    var body: some View {
        TrimmerHandles(
            start: $store.leftHandlePosition,
            end: $store.rightHandlePosition,
            totalWidth: store.bottomStripWidth,
            duration: store.videoDuration,
            minimumDuration: store.minimumDuration,
            maximumDuration: store.maximumDuration,
            accentColor: Color.white,
            showHandleTimestamps: false,
            height: height,
            onLeftHandleDrag: { position in
                store.send(.binding(.set(\.isPlaying, false)))
                store.send(.updateCursorPosition(position))
            },
            onRightHandleDrag: { position in
                store.send(.binding(.set(\.isPlaying, false)))
                store.send(.updateCursorPosition(position))
            },
            onLeftHandleDragEnd: { _ in
                store.send(.handleDragEnd)
            },
            onRightHandleDragEnd: { _ in
                store.send(.handleDragEnd)
            },
            onScrub: { _ in },
            onSelectionDrag: { startOffset in
                store.send(.binding(.set(\.isPlaying, false)))
                store.send(.dragSelection(startOffset: startOffset))
            },
            onSelectionDragEnd: {
                store.send(.handleDragEnd)
            }
        )
    }
}

private struct TrimmerPlaybackCursorView: View {
    @Bindable var store: StoreOf<VideoTrimmerV2>
    let height: CGFloat

    var body: some View {
        PlaybackCursor(
            start: $store.leftHandlePosition,
            end: $store.rightHandlePosition,
            totalWidth: store.bottomStripWidth,
            offset: store.cursorOffset,
            height: height,
            onScrub: { store.send(.updateCursorPosition($0)) },
            onScrubEnd: { store.send(.handleDragEnd) }
        )
    }
}

// MARK: - Sheets

private extension View {
    func HooksEditorSheets(store: StoreOf<HooksEditor>) -> some View {
        modifier(HooksEditorSheetModifier(store: store))
    }
}

private struct HooksEditorSheetModifier: ViewModifier {
    @Bindable var store: StoreOf<HooksEditor>

    func body(content: Content) -> some View {
        content
            .sheet(
                item: $store.scope(
                    state: \.destination?.mediaPicker,
                    action: \.destination.mediaPicker
                )
            ) { _ in
                VideoPicker(
                    selectedMovie: $store.selectedMovie.sending(\.setSelectedMovie),
                    transferProgress: $store.mediaTransferProgress.sending(\.setMediaTransferProgress),
                    onSelectionComplete: {
                        Task { [movieSelected = store.selectedMovie] in
                            if let movieURL = movieSelected?.url,
                               let movieDuration = await movieURL.videoDuration()
                            {
                                Task { @MainActor in
                                    store.send(.cameraRecordingFinished(
                                        localURL: movieURL,
                                        duration: movieDuration
                                    ))
                                    store.send(.dismissMediaPicker)
                                }
                            }
                        }
                    },
                    onSelectionCancelled: {
                        store.send(.dismissMediaPicker)
                    },
                    onError: { _ in
                        store.send(.mediaPickerDidError)
                    }
                )
            }
            .sheet(
                item: $store.scope(
                    state: \.destination?.volumeSelector,
                    action: \.destination.volumeSelector
                )
            ) { scopedStore in
                HooksVolumeSelectorView(store: scopedStore)
                    .presentationCornerRadius(30, conditional: true)
                    .presentationDragIndicator(.visible)
                    .environment(\.colorScheme, .dark)
            }
    }
}

// MARK: URL Extension

private extension URL {
    /// Returns the duration of the video at this URL, or nil if it can't be determined.
    func videoDuration() async -> TimeInterval? {
        let asset = AVAsset(url: self)
        guard let duration = try? await asset.load(.duration) else { return nil }
        guard duration.isValid, duration.isNumeric else { return nil }
        return CMTimeGetSeconds(duration)
    }
}
