import APIClient
import AVFoundation
import ComponentLibrary
import ComposableArchitecture
import Localization
import MediaPlayer
import PlayerClient
import SwiftUI
import Utilities

// swiftlint:disable file_length

/*
 Separate from our regular Player in `Player.swift`,
 EditClipPlayer has limited functionality and is
 specifically designed for the EditClipCoordinator.
 */
@Reducer
public struct EditClipPlayer {
    @ObservableState
    public struct State: Equatable, Identifiable {
        public var id: Clip.ID { clipItems.first?.clip.id ?? .init(remoteId: "") }
        public var rootClipItem: ChildClip
        public var clipItems: [ChildClip] = []
        public var currentClip: Clip?
        public var elapsedTime: CMTime = .zero
        public var totalTime: CMTime = .zero
        public var fallbackTotalTimeSeconds: Double = 120
        public var currentClipTotalTimeSeconds: Double {
            totalTime.isInvalid ? fallbackTotalTimeSeconds : totalTime.seconds
        }

        public var isReplacingCurrentItem: Bool = false

        public var timeControlStatus: AVPlayer.TimeControlStatus = .waitingToPlayAtSpecifiedRate {
            didSet {
                $isPlaying.withLock { $0 = timeControlStatus == .playing }
            }
        }

        @Shared(.inMemory(.playingClipKey)) var playingClip: Clip?
        @Shared(.inMemory(.isPlayingKey)) var isPlaying: Bool = false

        // Makes sure we load the clip but don't
        // play it right away on first load
        public var didPlayForFirstTime: Bool = false

        // Used to disable the next/previous buttons
        // when we're at the first or last clip
        public var canPlayNext: Bool {
            guard let currentClip = currentClip,
                  let currentIndex = clipItems.firstIndex(where: { $0.clip.id == currentClip.id })
            else { return false }
            return currentIndex < clipItems.count - 1
        }

        public var canPlayPrevious: Bool {
            guard let currentClip = currentClip,
                  let currentIndex = clipItems.firstIndex(where: { $0.clip.id == currentClip.id })
            else { return false }
            return currentIndex > 0
        }

        // Scrubbing state
        public enum ScrubState: Equatable, Hashable {
            case idle
            case scrubbing(CMTime)
            case complete(CMTime)

            var time: CMTime {
                switch self {
                case .idle: return .zero
                case .scrubbing(let time): return time
                case .complete(let time): return time
                }
            }
        }

        public var scrub: ScrubState?
        public var isScrubbing: Bool { scrub != nil }
        public var displayTime: CMTime { scrub?.time ?? elapsedTime }

        public init(rootClip: Clip) {
            self.rootClipItem = ChildClip(clip: rootClip)
        }
    }

    public enum Action: BindableAction {
        case prepareForPlayback
        case setup
        case play
        case pause
        case seek(Double)
        case playNext
        case playPrevious
        case `internal`(Internal)
        case delegate(Delegate)
        case binding(BindingAction<State>)
        case teardown
        case loadClips([ChildClip])
        case addNewClips([ChildClip])
        case togglePlayPause(Clip)
        case prepareClip(Clip)
        case replaceAllClips([ChildClip])
        case removeClip(ChildClip)

        public enum Internal {
            case setupCompletion(Result<Void, Error>)
            case timeControlStatusResponse(Result<AVPlayer.TimeControlStatus, Error>)
            case periodicTimeResponse(CMTime)
            case replaceCurrentItem(ChildClip)
            case replaceCurrentItemResponse(ChildClip, CMTime)
            case didPlayToEndTime
            case incrementPlayCountMetrics(Clip)
            case incrementActionResponse(Result<Void, Error>)
        }

        public enum Delegate {
            case setupComplete
            case didPlayClip(Clip)
            case updatePlayerElapsedTime(Double)
        }
    }

    @Dependency(PlayerClient.self) var playerClient
    @Dependency(APIClient.self) var apiClient
    @Dependency(\.mainQueue) var mainQueue
    @Dependency(\.telemetryClient) var telemetry

    public var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            struct TimeControlStatusPublisherCancellable: Hashable {}
            struct ElapsedTimePublisherCancellable: Hashable {}
            struct DidPlayToEndTimeNotificationCancellable: Hashable {}

            switch action {
            case .setup:
                return .merge(
                    .run { send in
                        await send(.internal(.setupCompletion(Result(catching: { try playerClient.setup() }))))
                    },
                    .publisher {
                        playerClient.timeControlStatus()
                            .map { .internal(.timeControlStatusResponse(.success($0))) }
                    }
                    .cancellable(id: TimeControlStatusPublisherCancellable(), cancelInFlight: true)
                )

            case .teardown:
                playerClient.pause()
                playerClient.seek(.zero)
                return .none

            case .internal(.setupCompletion(.success)):
                return .send(.delegate(.setupComplete))

            case .internal(.setupCompletion(.failure(let error))):
                log.telemetry.error(error)
                return .none

            case .internal(.timeControlStatusResponse(.success(let status))):
                guard !state.isReplacingCurrentItem else { return .none }
                state.timeControlStatus = status
                return .none

            case .internal(.timeControlStatusResponse(.failure(let error))):
                log.telemetry.error(error)
                return .none

            case .internal(.replaceCurrentItem(let item)):
                guard !item.clip.audioUrl.isEmpty else { return .none }
                state.isReplacingCurrentItem = true
                state.currentClip = item.clip

                let url = item.clip.playableSceneUrl?.absoluteString ?? item.clip.audioUrl
                return .run { send in
                    let duration = await playerClient.replaceCurrentItem(url)
                    await send(.internal(.replaceCurrentItemResponse(item, duration)))
                }

            case let .internal(.replaceCurrentItemResponse(_, duration)):
                state.totalTime = duration
                state.isReplacingCurrentItem = false
                return .merge(
                    .run { [didPlayForFirstTime = state.didPlayForFirstTime] send in
                        guard didPlayForFirstTime else { return }
                        await send(.play)
                    },
                    .publisher {
                        playerClient.periodicTime()
                            .map { .internal(.periodicTimeResponse($0)) }
                    }
                    .cancellable(id: ElapsedTimePublisherCancellable(), cancelInFlight: true),
                    .run { [currentClip = state.currentClip] send in
                        guard let currentClip else { return }
                        await send(.delegate(.didPlayClip(currentClip)))
                        await send(.internal(.incrementPlayCountMetrics(currentClip)))
                    }
                )

            case .internal(.periodicTimeResponse(let time)):
                state.elapsedTime = time

                // If we're scrubbing or just completed scrubbing,
                // use that elapsed time. If we just finished scrubbing,
                // clear the scrub state so the progress bar can reset
                // its size.
                if case .complete(let time) = state.scrub {
                    state.elapsedTime = time
                    state.scrub = nil
                } else if case .scrubbing(let time) = state.scrub {
                    state.elapsedTime = time
                }

                return .send(.delegate(.updatePlayerElapsedTime(time.seconds)))

            case .internal(.didPlayToEndTime):
                playerClient.seek(.zero)
                playerClient.play()
                return .none

            case let .internal(.incrementPlayCountMetrics(clip)):
                return .run { send in
                    await send(.internal(.incrementActionResponse(.init(catching: { try await apiClient.incrementPlayCount(clip) }))))
                }

            case .internal(.incrementActionResponse(.success)):
                return .none

            case .internal(.incrementActionResponse(.failure(let error))):
                log.telemetry.error(error)
                return .none

            case .play:
                playerClient.play()
                state.$playingClip.withLock { $0 = state.currentClip }
                state.didPlayForFirstTime = true
                return .publisher {
                    playerClient.periodicTime()
                        .map { .internal(.periodicTimeResponse($0)) }
                }
                .cancellable(id: ElapsedTimePublisherCancellable(), cancelInFlight: true)

            case .pause:
                playerClient.pause()
                return .none

            case .seek(let time):
                let cmTime = CMTime(seconds: time, preferredTimescale: 1000)
                return .run { _ in
                    playerClient.seek(cmTime)
                }

            case .playNext:
                guard let currentClip = state.currentClip,
                      let currentIndex = state.clipItems.firstIndex(where: { $0.clip.id == currentClip.id })
                else { return .none }
                let nextIndex = currentIndex + 1
                guard nextIndex < state.clipItems.count else { return .none }
                return .send(.internal(.replaceCurrentItem(state.clipItems[nextIndex])))

            case .playPrevious:
                guard let currentClip = state.currentClip,
                      let currentIndex = state.clipItems.firstIndex(where: { $0.clip.id == currentClip.id })
                else { return .none }
                let previousIndex = currentIndex - 1
                guard previousIndex >= 0 else { return .none }
                return .send(.internal(.replaceCurrentItem(state.clipItems[previousIndex])))

            case .loadClips(let clipItems):
                for clipItem in clipItems {
                    state.clipItems.append(clipItem)
                }

                return .send(.internal(.replaceCurrentItem(state.rootClipItem)))

            case .addNewClips(let clipItems):
                // If this clip doesn't have any edits yet,
                // or if we haven't started playing in this session,
                // play the first clip that comes in
                let hasNoEdits = state.clipItems.count == 1

                for clipItem in clipItems {
                    if state.clipItems.isEmpty {
                        state.clipItems.append(clipItem)
                    } else {
                        let insertIndex = min(1, state.clipItems.count)
                        state.clipItems.insert(clipItem, at: insertIndex)
                    }
                }

                guard let firstClipItem = state.clipItems.first(where: { $0.clip.id != state.rootClipItem.clip.id }),
                      firstClipItem.clip.status == .complete || firstClipItem.clip.status == .streaming,
                      hasNoEdits
                else { return .none }
                return .send(.internal(.replaceCurrentItem(firstClipItem)))

            case .togglePlayPause(let clip):
                state.didPlayForFirstTime = true
                // Pause the clip if it's already playing
                guard !state.isPlaying || (state.isPlaying && state.playingClip != clip) else {
                    return .send(.pause)
                }

                guard let clipItem = state.clipItems.first(where: { $0.clip.id == clip.id }) else { return .none }
                return .send(.internal(.replaceCurrentItem(clipItem)))

            case .prepareForPlayback:
                // Make sure we don't play the clip right away if we were before
                state.didPlayForFirstTime = false

                // Setup the playerClient with the correct clip
                // We don't use `.internal(.replaceCurrentItem)` here because that's reserved for
                // replacing the current item with a new clip and the effects (like incrementing play count)
                // that come with that.
                guard let currentClip = state.currentClip,
                      let clipItem = state.clipItems.first(where: { $0.clip.id == currentClip.id })
                else { return .none }
                return .run { _ in
                    let url = clipItem.clip.audioUrl
                    _ = await playerClient.replaceCurrentItem(url)
                }

            case .prepareClip(let clip):
                state.didPlayForFirstTime = false
                state.currentClip = clip
                guard let clipItem = state.clipItems.first(where: { $0.clip.id == clip.id }) else { return .none }
                return .send(.internal(.replaceCurrentItem(clipItem)))

            case .replaceAllClips(let clipItems):
                state.clipItems = clipItems
                state.currentClip = clipItems.first?.clip
                guard let currentClip = state.currentClip else { return .none }
                return .send(.prepareClip(currentClip))

            case .removeClip(let clipItem):
                state.clipItems.removeAll(where: { $0.clip.id == clipItem.clip.id })
                return .none

            case .binding(\.scrub):
                switch state.scrub {
                case .idle, .none:
                    return .none

                case .scrubbing(let time):
                    return .run { send in
                        await send(.seek(time.seconds))
                    }

                case .complete(let time):
                    return .run { send in
                        await send(.seek(time.seconds))
                    }
                }

            case .delegate, .binding:
                return .none
            }
        }
    }
}

public struct EditClipPlayerView: View {
    let store: StoreOf<EditClipPlayer>
    let width = UIScreen.width
    let clip: Clip?

    public init(store: StoreOf<EditClipPlayer>) {
        self.store = store
        self.clip = store.currentClip
    }

    public var isReadyToPlay: Bool {
        (clip?.status == .complete || clip?.status == .streaming)
    }

    public var label: String {
        guard let clip = clip,
              let clipItem = store.clipItems.first(where: { $0.clip.id == clip.id })
        else { return "" }
        guard let history = clip.history, !history.clips.isEmpty else { return L10n.FeatureEditClip.original }
        return "\(L10n.FeatureEditClip.extension) #\(clipItem.index)"
    }

    public var body: some View {
        ZStack(alignment: .bottom) {
            ZStack {
                clipInfo
                EditClipPlayerControls(
                    isReadyToPlay: !(store.timeControlStatus == .waitingToPlayAtSpecifiedRate),
                    isPlaying: store.isPlaying,
                    onPlay: { store.send(.play) },
                    onPause: { store.send(.pause) },
                    onTapNext: { store.send(.playNext) },
                    onTapPrevious: { store.send(.playPrevious) },
                    onSeekForward: { store.send(.seek(10)) },
                    onSeekBackward: { store.send(.seek(-10)) },
                    canPlayNext: store.canPlayNext,
                    canPlayPrevious: store.canPlayPrevious
                )
                .frame(width: 136, alignment: .trailing)
                .padding(.leading, 8) // To cover long song titles as they scroll (in the future)
                .background(Color.SemanticV1.backgroundSecondary)
                .frame(maxWidth: .infinity, alignment: .trailing)
            }
            .padding(.horizontal, 15)
            .padding(.bottom, 30)
            .padding(.top, 9)
            .frame(maxWidth: .infinity)
            if let clip {
                EditClipPlayerProgressBar(
                    store: store,
                    clip: clip,
                    width: width
                )
                .padding(.bottom, 12)
            }
        }
        .frame(width: width)
        .task {
            store.send(.setup)
        }
    }

    @ViewBuilder
    private var clipInfo: some View {
        if let clip {
            HStack(spacing: 16) {
                image
                VStack(alignment: .leading, spacing: 1) {
                    scrollingTitleBar
                        .foregroundStyle(Color.SemanticV1.textPrimary)
                        .lineLimit(1)
                        .frame(height: 24, alignment: .center)
                    HStack(spacing: 4) {
                        if clip.highLevelModel == .v4 {
                            Text("v4")
                                .typographyV1(.caption4.neueMontrealMedium())
                                .foregroundStyle(Color.SemanticV1.textPrimary)
                                .lineLimit(1)
                                .minimumScaleFactor(0.75)
                                .padding(.vertical, 2)
                                .padding(.horizontal, 6)
                                .background(RoundedRectangle(cornerRadius: 4).strokeBorder(Color.SemanticV1.textTertiary, lineWidth: 1.0))
                        }
                        Text(label)
                            .typographyV1(.caption4.neueMontrealMedium())
                            .foregroundStyle(Color.SemanticV1.textPrimary)
                            .lineLimit(1)
                            .minimumScaleFactor(0.75)
                            .padding(.vertical, 2)
                            .padding(.horizontal, 6)
                            .background(
                                RoundedRectangle(cornerRadius: 4)
                                    .strokeBorder(Color.SemanticV1.textTertiary, lineWidth: 1)
                            )
                    }
                    .frame(height: 20)
                }
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .transition(.opacity)
            .animation(.easeInOut(duration: 0.25), value: label)
        }
    }

    @ViewBuilder
    private var image: some View {
        ZStack {
            RoundedRectangle(cornerRadius: 4)
                .foregroundColor(Color.SemanticV1.backgroundTertiary)
                .frame(width: 46, height: 46)
            if isReadyToPlay, let clip {
                RemoteImage(url: clip.largeImageUrl, fallbackId: clip.id.remoteId)
                    .aspectRatio(4 / 3, contentMode: .fill)
                    .frame(width: 46, height: 46)
                    .clipShape(RoundedRectangle(cornerRadius: 4))
            } else {
                GradientSpinner(size: .extraLarge,
                                startColor: Color.SemanticV1.auraPink,
                                endColor: Color.SemanticV1.v4Blue)
            }
        }
        .frame(width: 46, height: 46)
    }

    @ViewBuilder
    private var scrollingTitleBar: some View {
        if let clip {
            MarqueeText(
                text: clip.title,
                font: TypographyV1.body2thin.lineHeight(16.0).neueMontrealMedium().uiFont ?? .systemFont(ofSize: 15),
                leftFade: 4,
                rightFade: 4,
                startDelay: 3
            )
        }
    }
}
