import APIClient
import AudioRecorderClient
import AVFoundation
import ComponentLibrary
import ComposableArchitecture
import FeatureToasts
import Localization
import PlayerClient
import StatsigClient
import SunoModelClient
import SwiftUI
import UserDefaultsClient
import Utilities

// swiftlint:disable file_length

@Reducer
public struct Audio {
    @Reducer(state: .equatable)
    public enum Destination {
        case recording(AudioRecorder)
        case documentPicker
        case libraryPicker(AudioLibraryPicker)
    }

    @ObservableState
    public struct State: Equatable {
        @Presents public var destination: Destination.State?
        @ObservationStateIgnored @ObservedBox var toastState = ToastReducer.State()

        @Shared var me: Me
        public var buttonState: VersioningPill.DisplayState = .loading

        public var autoRecord: Bool = false

        public var isPending: Bool {
            switch destination {
            case .recording:
                return false
            default:
                return true
            }
        }

        @Shared(.inMemory(.billingInfo)) var billingInfo: SubscriptionInfoResponse?

        public init(me: Shared<Me>, autoRecord: Bool = false) {
            self._me = me
            self.autoRecord = autoRecord
        }
    }

    public enum Action: BindableAction {
        public enum Delegate {
            case generationResponse(Prompt, Result<[Clip], Error>)
            case showHowToUseAudio
            case showAudioUploadTermsTooltip
            case didPressUpgradeModel
            case didPressModelForInformation
            case didPressAudioLengthUpsell

            case handleAudioRecording(AudioRecording)
            case handleUseExistingClip(Clip, downloadedUrl: URL)
        }

        case task
        case binding(BindingAction<State>)
        case delegate(Delegate)
        case destination(PresentationAction<Destination.Action>)
        case toastAction(ToastReducer.Action)
        case startRecording
        case reset
        case infoTapped
        case uploadTapped
        case folderTapped
        case processAudioFile(Result<URL, Error>)
        case processAudioFileDidFinish(URL, String, Result<CMTime, Error>)
    }

    private var newRecordingAudio: AudioRecorder.State {
        AudioRecorder.State(
            date: date.now,
            url: temporaryDirectory()
                .appendingPathComponent(uuid().uuidString)
                .appendingPathExtension("m4a")
        )
    }

    @Dependency(\.date) private var date
    @Dependency(\.temporaryDirectory) private var temporaryDirectory
    @Dependency(\.uuid) private var uuid
    @Dependency(UserDefaultsClient.self) private var userDefaults

    public init() {}

    public var body: some ReducerOf<Self> {
        Scope(state: \.toastState, action: \.toastAction) {
            ToastReducer()
        }
        BindingReducer()
        Reduce<State, Action> { state, action in
            switch action {
            case .task:
                // Check if we're coming from a "Start recording" shortcut
                if state.autoRecord {
                    return .send(.startRecording)
                }
                return .run { _ in
                    // Pause currently playing OmniPlayer clip when we start recording
                    @Dependency(\.omniplayerClient.pauseCurrentClip) var pauseCurrentClip
                    pauseCurrentClip()
                }

            case .reset:
                state.destination = nil
                return .none

            case .startRecording:
                guard userDefaults.hasAcceptedAudioUploadTOS else { return .send(.delegate(.showAudioUploadTermsTooltip)) }
                state.destination = .recording(newRecordingAudio)
                return .none

            case .destination(.presented(.recording(.delegate(.didFinish(.success(let audioRecording)))))):
                return .send(.delegate(.handleAudioRecording(audioRecording)))

            case .destination(.presented(.recording(.delegate(.didFinish(.failure(let error)))))):
                log.telemetry.error(error)
                state.destination = nil
                return .send(.delegate(.generationResponse(Prompt(), .failure(error))))

            case
                .destination(.presented(.recording(.delegate(.dismiss)))): // When dismissed while recording
                state.destination = nil
                state.autoRecord = false
                return .none

            case .destination(.presented(.libraryPicker(.delegate(.downloadedClip(let url, let clip))))):
                return .send(.delegate(.handleUseExistingClip(clip, downloadedUrl: url)))

            case .infoTapped:
                return .send(.delegate(.showHowToUseAudio))

            case .folderTapped:
                guard userDefaults.hasAcceptedAudioUploadTOS else { return .send(.delegate(.showAudioUploadTermsTooltip)) }
                state.destination = .libraryPicker(.init(me: state.$me))
                return .none

            case .uploadTapped:
                guard userDefaults.hasAcceptedAudioUploadTOS else { return .send(.delegate(.showAudioUploadTermsTooltip)) }
                state.destination = .documentPicker
                return .none

            case .processAudioFile(.success(let originalUrl)):
                let tempUrl = temporaryDirectory().appendingPathComponent(uuid().uuidString).appendingPathExtension(originalUrl.pathExtension)
                let fileName = originalUrl.deletingPathExtension().lastPathComponent

                // Create a copy from original URL instead of passing directly so if we discard the audio while uploading we don't delete from original source
                do {
                    try FileManager.default.copyItem(at: originalUrl, to: tempUrl)
                } catch {
                    return .send(.processAudioFile(.failure(error)))
                }

                return .run { send in
                    let asset = AVURLAsset(url: tempUrl)
                    await send(.processAudioFileDidFinish(tempUrl, fileName, Result(catching: { try await asset.load(.duration) })))
                }

            case .processAudioFile(.failure(let error)):
                return .send(.toastAction(.show(.warning(L10n.FeatureCreateClip.error, .string(error.underlyingError)))))

            case .processAudioFileDidFinish(let tempUrl, let fileName, .success(let duration)):
                let audioRecording = AudioRecording(
                    date: date.now,
                    duration: duration.seconds,
                    title: {
                        guard !fileName.isEmpty else {
                            let formatter = DateFormatter()
                            formatter.dateFormat = "MM-dd-yyyy_HH:mm:ss"
                            return formatter.string(from: Date())
                        }
                        return fileName
                    }(),
                    url: tempUrl
                )
                return .send(.delegate(.handleAudioRecording(audioRecording)))

            case .processAudioFileDidFinish(_, _, .failure(let error)):
                return .send(.toastAction(.show(.warning(L10n.FeatureCreateClip.error, .string(error.underlyingError)))))

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

            case .toastAction:
                // Catch-all
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)
    }
}

struct AudioRecorderError: Error {}

@Reducer
public struct AudioRecorder {
    @Reducer(state: .equatable)
    public enum Destination {
        case deletionAlert(AlertState<DeletionAlert>)

        public enum DeletionAlert {
            case confirm
        }
    }

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

        var date: Date
        var duration: TimeInterval = 0
        var url: URL
        var samples: [TimeInterval: CGFloat] = [:]
        var startDate: Date?
        var showProgressView: Bool = false

        var maxRecordTime: TimeInterval {
            @Shared(.inMemory(.billingInfo)) var billingInfo: SubscriptionInfoResponse?
            if let billingInfo {
                return TimeInterval(billingInfo.audioUploadLimits.max)
            } else {
                return 60 // Fallback to 60
            }
        }
    }

    public enum Action {
        @CasePathable
        public enum Delegate: Sendable {
            case didFinish(Result<AudioRecording, Error>)
            case dismiss
        }

        case destination(PresentationAction<Destination.Action>)
        case delegate(Delegate)

        case task
        case stopButtonTapped
        case audioRecorderDidFinish(Result<Bool, Error>)
        case finalRecordingTime(TimeInterval)
        case timerUpdated
        case updateSamples(PowerLevels)
        case restartTapped
        case trashTapped
    }

    @Dependency(\.audioRecorder) private var audioRecorder
    @Dependency(\.continuousClock) private var clock

    public var body: some ReducerOf<Self> {
        Reduce<State, Action> { state, action in
            struct ClockCancellable: Hashable {}
            switch action {
            case .task:
                state.startDate = Date()
                return .run { [url = state.url] send in
                    async let startRecording: Void = send(.audioRecorderDidFinish(Result { try await self.audioRecorder.startRecording(url: url, shouldStop: true) }))
                    for await _ in self.clock.timer(interval: .seconds(0.1)) {
                        await send(.timerUpdated)
                    }
                    await startRecording
                }
                .cancellable(id: ClockCancellable())

            case .audioRecorderDidFinish(.success(true)):
                return .concatenate(
                    .cancel(id: ClockCancellable()),
                    .send(
                        .delegate(
                            .didFinish(
                                .success(
                                    AudioRecording(date: state.date, duration: state.duration, title: { let formatter = DateFormatter()
                                        formatter.dateFormat = "MM-dd-yyyy_HH:mm:ss"
                                        return formatter.string(from: Date())
                                    }(), url: state.url)
                                )
                            )
                        )
                    )
                )

            case .audioRecorderDidFinish(.success(false)):
                return .send(.delegate(.didFinish(.failure(AudioRecorderError()))))

            case .audioRecorderDidFinish(.failure(let error)):
                return .send(.delegate(.didFinish(.failure(error))))

            case .finalRecordingTime(let duration):
                state.duration = duration
                return .none

            case .stopButtonTapped:
                state.showProgressView = true
                return .run { send in
                    if let currentTime = await audioRecorder.currentTime() {
                        await send(.finalRecordingTime(currentTime))
                    }
                    await audioRecorder.stopRecording()
                }

            case .timerUpdated:
                state.duration = Date().timeIntervalSince(state.startDate ?? Date())
                if state.duration >= state.maxRecordTime {
                    return .send(.stopButtonTapped)
                }
                return .run { send in
                    await send(.updateSamples(await audioRecorder.powerLevels()))
                }

            case .updateSamples(let powerLevels):
                state.samples[state.duration] = max(0.01, CGFloat(powerLevels.averagePower + 40) / 40)
                return .none

            case .restartTapped:
                state.duration = 0
                state.samples.removeAll()
                state.startDate = Date()
                return .run { [url = state.url] send in
                    try? FileManager.default.removeItem(at: url)
                    await send(.audioRecorderDidFinish(Result { try await self.audioRecorder.startRecording(url: url, shouldStop: false) }))
                }

            case .trashTapped:
                state.destination = .deletionAlert(.confirmDelete)
                return .none

            case .destination(.presented(.deletionAlert(.confirm))):
                // Delete record file and dismiss
                return .run { [url = state.url] send in
                    try? FileManager.default.removeItem(at: url)
                    await send(.delegate(.dismiss))
                }

            case .destination:
                // Catch-all
                return .none

            case .delegate:
                return .none
            }
        }
        .ifLet(\.$destination, action: \.destination)
    }
}

public struct AudioView: View {
    @Namespace private var namespace
    @Bindable private var store: StoreOf<Audio>

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

    public var body: some View {
        content
            .animation(.default, value: store.destination)
    }

    @ViewBuilder private var content: some View {
        ZStack {
            switch store.destination {
            case .recording:
                if let store = store.scope(state: \.destination?.recording, action: \.destination.recording) {
                    AudioRecorderView(namespace: namespace, store: store)
                }

            default:
                AudioPendingView(namespace: namespace, store: store)
            }
        }
        .clipped()
    }
}

struct AudioPendingView: View {
    var namespace: Namespace.ID
    @Bindable var store: StoreOf<Audio>

    let phrases: [String] = [
        L10n.FeatureCreateClip.humATune,
        L10n.FeatureCreateClip.tapABeat,
        L10n.FeatureCreateClip.singAMelody,
    ]

    @State var selectedPhraseIndex: Int = 0

    var body: some View {
        VStack {
            Color.SemanticV2.backgroundSecondary
                .clipShape(.rect(cornerRadius: 16))
                .padding(10)
                .matchedGeometryEffect(id: audioBackground, in: namespace)
                .overlay {
                    VStack(spacing: 21) {
                        Image.Assets.audioIcons

                        ZStack {
                            ForEach(Array(phrases.enumerated()), id: \.offset) { index, phrase in
                                if index == selectedPhraseIndex {
                                    Text(phrase)
                                        .typographyV1(.headline3.neueMontrealMedium())
                                        .foregroundStyle(Color.SemanticV1.textTertiary)
                                        .transition(
                                            .asymmetric(
                                                insertion: .move(edge: .bottom).combined(with: .opacity),
                                                removal: .move(edge: .top).combined(with: .opacity)
                                            )
                                        )
                                        .task {
                                            do {
                                                try await Task.sleep(for: .seconds(2))
                                                if selectedPhraseIndex == phrases.count - 1 {
                                                    selectedPhraseIndex = 0
                                                } else {
                                                    selectedPhraseIndex = selectedPhraseIndex + 1
                                                }
                                            } catch {
                                                // do nothing
                                            }
                                        }
                                }
                            }
                        }
                        .animation(.spring, value: selectedPhraseIndex)

                        Button(
                            action: { store.send(.infoTapped) },
                            label: {
                                HStack(spacing: 8) {
                                    Text(L10n.FeatureCreateClip.tapToRecord)
                                    Image.Icon.informationThick
                                }
                                .typographyV1(.body2)
                                .foregroundStyle(Color.SemanticV1.textTertiary)
                                .padding(20)
                            }
                        )
                    }
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                }

            HStack {
                Button(
                    action: {
                        UIImpactFeedbackGenerator(style: .light).impactOccurred()
                        store.send(.uploadTapped)
                    },
                    label: {
                        Image.Icon.upload
                            .foregroundStyle(Color.SemanticV1.iconBrand)
                            .padding(5) // Increase the tap target
                            .contentShape(.rect)
                    }
                )
                .matchedGeometryEffect(id: left, in: namespace)
                .buttonStyle(.plain)

                Spacer()

                Button(
                    action: {
                        UIImpactFeedbackGenerator(style: .medium).impactOccurred()
                        store.send(.startRecording)
                    },
                    label: {
                        Shutter()
                            .matchedGeometryEffect(id: actionButton, in: namespace)
                    }
                )

                Spacer()

                Button(
                    action: {
                        UIImpactFeedbackGenerator(style: .light).impactOccurred()
                        store.send(.folderTapped)
                    },
                    label: {
                        Image.Icon.musicFolder
                            .foregroundStyle(Color.SemanticV1.iconBrand)
                            .padding(5) // Increase the tap target
                            .contentShape(.rect)
                    }
                )
                .matchedGeometryEffect(id: right, in: namespace)
                .buttonStyle(.plain)
            }
            .padding([.horizontal, .bottom], 12)

            /// Seems to need to be an `if-else` due to the attributed string init
            if store.billingInfo?.plan == nil {
                Text(attributedAudioLengthUpsell)
                    .typographyV1(.caption6.inputSans())
                    .foregroundStyle(Color.SemanticV2.foregroundTertiary)
                    .environment(\.openURL, OpenURLAction { _ in
                        store.send(.delegate(.didPressAudioLengthUpsell))
                        return .handled
                    })
            } else {
                Text(L10n.FeatureCreateClip.audioLengthProUserCaption)
                    .typographyV1(.caption6.inputSans())
                    .foregroundStyle(Color.SemanticV2.foregroundTertiary)
            }
        }
        .overlay(alignment: .top) {
            ToastView(store: store.scope(state: \.toastState, action: \.toastAction))
        }
        .fullScreenCover(item: $store.scope(state: \.destination?.documentPicker, action: \.destination.documentPicker)) { _ in
            DocumentPickerView { result in
                switch result {
                case .success(let url):
                    store.send(.processAudioFile(.success(url)))
                case .failure(let error):
                    store.send(.processAudioFile(.failure(error)))
                }
            }
        }
        .fullScreenCover(item: $store.scope(state: \.destination?.libraryPicker, action: \.destination.libraryPicker)) { store in
            NavigationStack {
                AudioLibraryPickerScreen(store: store)
                    .toolbar {
                        ToolbarItem(placement: .topBarLeading) {
                            ToolbarButton(.close, background: Material.ultraThin) { store.send(.dismiss) }
                        }
                    }
            }
        }
        .task { store.send(.task) }
    }

    private var attributedAudioLengthUpsell: AttributedString {
        let baseString = L10n.FeatureCreateClip.audioLengthUpsellCaption
        var text = (try? AttributedString(markdown: baseString)) ?? AttributedString(baseString)
        let runsWithLinks = text.runs.filter { run in
            run.link != nil
        }

        for run in runsWithLinks {
            text[run.range].foregroundColor = .SemanticV2.foregroundTertiary
            text[run.range].underlineStyle = .init(pattern: .solid, color: .SemanticV2.foregroundTertiary)
        }
        return text
    }
}

struct AudioRecorderView: View {
    private static let dateComponentsFormatter: DateComponentsFormatter = {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.minute, .second]
        formatter.zeroFormattingBehavior = .pad
        return formatter
    }()

    var namespace: Namespace.ID
    @Bindable var store: StoreOf<AudioRecorder>

    var body: some View {
        VStack {
            Color.SemanticV2.backgroundSecondary
                .clipShape(.rect(cornerRadius: 16))
                .matchedGeometryEffect(id: audioBackground, in: namespace)
                .overlay {
                    VStack(spacing: 0) {
                        LinearGradient(
                            gradient: Gradient(colors: [
                                .init(hue: 0.68, saturation: 1, brightness: 0.75),
                                .init(hue: 0.78, saturation: 0.29, brightness: 0.97),
                                .init(hue: 0.03, saturation: 0.9, brightness: 0.95),
                            ]),
                            startPoint: .bottom,
                            endPoint: .top
                        )
                        .mask {
                            WaveformView(samples: store.samples)
                        }
                        .overlay {
                            VStack(spacing: 16) {
                                Image.Icon.playFilled
                                    .rotationEffect(.degrees(90))

                                Rectangle()
                                    .fill(Color.SemanticV1.backgroundQuaternary)
                                    .frame(width: 2)
                            }
                        }

                        let formattedDuration = Self.dateComponentsFormatter.string(from: store.duration)

                        Text(formattedDuration ?? " ")
                            .typographyV1(.headline3.neueMontrealMedium())
                            .foregroundColor(.SemanticV1.textPrimary)
                            .padding(.top, 24)
                    }
                    .padding(10)
                }
                .padding(10)

            HStack(spacing: 36) {
                restartButton
                stopButton
                trashButton
            }
            .padding([.horizontal, .bottom], 24)
        }
        .alert($store.scope(state: \.destination?.deletionAlert, action: \.destination.deletionAlert))
        .task { store.send(.task) }
    }

    @ViewBuilder
    private var restartButton: some View {
        Button {
            UIImpactFeedbackGenerator(style: .light).impactOccurred()
            store.send(.restartTapped, animation: .default)
        } label: {
            Image.Icon.restart
        }
        .foregroundStyle(Color.SemanticV1.iconBrand)
        .frame(width: 40, height: 40)
        .matchedGeometryEffect(id: left, in: namespace)
    }

    @ViewBuilder
    private var stopButton: some View {
        Button {
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            store.send(.stopButtonTapped, animation: .default)
        } label: {
            Circle()
                .stroke(Color.SemanticV1.backgroundQuaternary, lineWidth: 4)
                .frame(width: 72, height: 72)
                .overlay {
                    Circle()
                        .trim(from: 0, to: store.duration / store.maxRecordTime)
                        .stroke(Color.SemanticV2.accentBrand, lineWidth: 4)
                        .frame(width: 72, height: 72)
                        .rotationEffect(.degrees(-90))
                        .animation(.linear, value: store.duration)
                }
                .overlay {
                    ZStack {
                        RoundedRectangle(cornerRadius: 4)
                            .frame(width: 24, height: 24)
                            .overlay {
                                Image.Assets.omniplayerBackground
                            }
                            .clipShape(RoundedRectangle(cornerRadius: 4))
                            .opacity(store.showProgressView ? 0 : 1)

                        ProgressView()
                            .progressViewStyle(CircularProgressViewStyle(tint: Color.SemanticV2.accentBrand))
                            .frame(width: 24, height: 24)
                            .opacity(store.showProgressView ? 1 : 0)
                    }
                }
                .matchedGeometryEffect(id: actionButton, in: namespace)
        }
    }

    @ViewBuilder
    private var trashButton: some View {
        Button {
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            store.send(.trashTapped, animation: .default)
        } label: {
            Image.Icon.trashV1
        }
        .foregroundStyle(Color.SemanticV1.iconBrand)
        .frame(width: 40, height: 40)
        .matchedGeometryEffect(id: right, in: namespace)
    }
}

extension AlertState where Action == AudioRecorder.Destination.DeletionAlert {
    static var confirmDelete: Self {
        Self {
            TextState(L10n.FeatureCreateClip.discard)
        } actions: {
            ButtonState(role: .cancel) {
                TextState(L10n.FeatureCreateClip.cancel)
            }
            ButtonState(role: .destructive, action: .confirm) {
                TextState(L10n.FeatureCreateClip.discard)
            }
        } message: {
            TextState(L10n.FeatureCreateClip.discardMessage)
        }
    }
}

private let audioBackground = "background"
private let actionButton = "shutter"
private let left = "left"
private let right = "right"
private let clipDetails = "clipDetails"
