//
//  ComposeController.swift
//  ComposeUI
//
//  Created by Shadowfacts on 3/4/23.
//

import SwiftUI
import Combine
import Pachyderm
import TuskerComponents
import MatchedGeometryPresentation
import CoreData

public final class ComposeController: ViewController {
    public typealias FetchAttachment = (URL) async  -> UIImage?
    public typealias FetchStatus = (String) -> (any StatusProtocol)?
    public typealias DisplayNameLabel = (any AccountProtocol, Font.TextStyle, CGFloat) -> AnyView
    public typealias CurrentAccountContainerView = (AnyView) -> AnyView
    public typealias ReplyContentView = (any StatusProtocol, @escaping (CGFloat) -> Void) -> AnyView
    public typealias EmojiImageView = (Emoji) -> AnyView
    
    @Published public private(set) var draft: Draft {
        didSet {
            assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
        }
    }
    @Published public var config: ComposeUIConfig
    @Published public var mastodonController: ComposeMastodonContext
    let fetchAvatar: AvatarImageView.FetchAvatar
    let fetchAttachment: FetchAttachment
    let fetchStatus: FetchStatus
    let displayNameLabel: DisplayNameLabel
    let currentAccountContainerView: CurrentAccountContainerView
    let replyContentView: ReplyContentView
    let emojiImageView: EmojiImageView
    
    @Published public var currentAccount: (any AccountProtocol)?
    @Published public var showToolbar = true
    @Published public var deleteDraftOnDisappear = true
    
    @Published var autocompleteController: AutocompleteController!
    @Published var toolbarController: ToolbarController!
    @Published var attachmentsListController: AttachmentsListController!
    
    // this property is here rather than on the AttachmentsListController so that the ComposeView
    // updates when it changes, because changes to it may alter postButtonEnabled
    @Published var attachmentsMissingDescriptions = Set<UUID>()
    @Published var focusedAttachment: (DraftAttachment, AttachmentThumbnailController)?
    let scrollToAttachment = PassthroughSubject<UUID, Never>()
    @Published var contentWarningBecomeFirstResponder = false
    @Published var mainComposeTextViewBecomeFirstResponder = false
    @Published var currentInput: (any ComposeInput)? = nil
    @Published var shouldEmojiAutocompletionBeginExpanded = false
    @Published var isShowingSaveDraftSheet = false
    @Published var isShowingDraftsList = false
    @Published var poster: PostService?
    @Published var postError: PostService.Error?
    @Published public private(set) var didPostSuccessfully = false
    @Published var hasChangedLanguageSelection = false
    
    private var isDisappearing = false
    private var userConfirmedDelete = false
    
    public var isPosting: Bool {
        poster != nil
    }
    
    var charactersRemaining: Int {
        let instanceFeatures = mastodonController.instanceFeatures
        let limit = instanceFeatures.maxStatusChars
        let cwCount = draft.contentWarningEnabled ? draft.contentWarning.count : 0
        return limit - (cwCount + CharacterCounter.count(text: draft.text, for: instanceFeatures))
    }
    
    var postButtonEnabled: Bool {
        draft.editedStatusID != nil ||
            (draft.hasContent
                && charactersRemaining >= 0
                && !isPosting
                && attachmentsListController.isValid
                && isPollValid)
    }
    
    private var isPollValid: Bool {
        draft.poll == nil || draft.poll!.pollOptions.allSatisfy { !$0.text.isEmpty }
    }
    
    public var navigationTitle: String {
        if let id = draft.inReplyToID,
           let status = fetchStatus(id) {
            return "Reply to @\(status.account.acct)"
        } else if draft.editedStatusID != nil {
            return "Edit Post"
        } else {
            return "New Post"
        }
    }
    
    public init(
        draft: Draft,
        config: ComposeUIConfig,
        mastodonController: ComposeMastodonContext,
        fetchAvatar: @escaping AvatarImageView.FetchAvatar,
        fetchAttachment: @escaping FetchAttachment,
        fetchStatus: @escaping FetchStatus,
        displayNameLabel: @escaping DisplayNameLabel,
        currentAccountContainerView: @escaping CurrentAccountContainerView = { $0 },
        replyContentView: @escaping ReplyContentView,
        emojiImageView: @escaping EmojiImageView
    ) {
        self.draft = draft
        assert(draft.managedObjectContext == DraftsPersistentContainer.shared.viewContext)
        self.config = config
        self.mastodonController = mastodonController
        self.fetchAvatar = fetchAvatar
        self.fetchAttachment = fetchAttachment
        self.fetchStatus = fetchStatus
        self.displayNameLabel = displayNameLabel
        self.currentAccountContainerView = currentAccountContainerView
        self.replyContentView = replyContentView
        self.emojiImageView = emojiImageView
        
        self.autocompleteController = AutocompleteController(parent: self)
        self.toolbarController = ToolbarController(parent: self)
        self.attachmentsListController = AttachmentsListController(parent: self)
        
        if #available(iOS 16.0, *) {
            NotificationCenter.default.addObserver(self, selector: #selector(currentInputModeChanged), name: UITextInputMode.currentInputModeDidChangeNotification, object: nil)
        }
        NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: DraftsPersistentContainer.shared.viewContext)
    }
    
    public var view: some View {
        ComposeView(poster: poster)
            .environment(\.managedObjectContext, DraftsPersistentContainer.shared.viewContext)
            .environmentObject(draft)
            .environmentObject(mastodonController.instanceFeatures)
            .environment(\.composeUIConfig, config)
    }
    
    @MainActor
    @objc private func managedObjectsDidChange(_ notification: Foundation.Notification) {
        if let deleted = notification.userInfo?[NSDeletedObjectsKey] as? Set<NSManagedObject>,
           deleted.contains(where: { $0.objectID == self.draft.objectID }),
           !isDisappearing {
            self.config.dismiss(.cancel)
        }
    }
    
    public func canPaste(itemProviders: [NSItemProvider]) -> Bool {
        guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: DraftAttachment.self) }) else {
            return false
        }
        if mastodonController.instanceFeatures.mastodonAttachmentRestrictions {
            if draft.draftAttachments.allSatisfy({ $0.type == .image }) {
                // if providers are videos, this technically allows invalid video/image combinations
                return itemProviders.count + draft.attachments.count <= 4
            } else {
                return false
            }
        } else {
            return true
        }
    }
    
    public func paste(itemProviders: [NSItemProvider]) {
        for provider in itemProviders where provider.canLoadObject(ofClass: DraftAttachment.self) {
            provider.loadObject(ofClass: DraftAttachment.self) { object, error in
                guard let attachment = object as? DraftAttachment else { return }
                DispatchQueue.main.async {
                    guard self.attachmentsListController.canAddAttachment else { return }
                    DraftsPersistentContainer.shared.viewContext.insert(attachment)
                    attachment.draft = self.draft
                    self.draft.attachments.add(attachment)
                }
            }
        }
    }
    
    @MainActor
    func cancel() {
        if draft.hasContent {
            isShowingSaveDraftSheet = true
        } else {
            deleteDraftOnDisappear = true
            config.dismiss(.cancel)
        }
    }
    
    @MainActor
    func cancel(deleteDraft: Bool) {
        deleteDraftOnDisappear = true
        userConfirmedDelete = deleteDraft
        config.dismiss(.cancel)
    }
    
    func postStatus() {
        guard !isPosting,
              draft.editedStatusID != nil || draft.hasContent else {
            return
        }
        
        Task { @MainActor in
            let poster = PostService(mastodonController: mastodonController, config: config, draft: draft)
            self.poster = poster
            
            // try to resign the first responder, if there is one.
            // otherwise, the existence of the poster changes the .disabled modifier which causes the keyboard to hide
            // and the first responder to change during a view update, which in turn triggers a bunch of state changes
            UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
            
            do {
                try await poster.post()
                
                deleteDraftOnDisappear = true
                didPostSuccessfully = true
                
                // wait .25 seconds so the user can see the progress bar has completed
                try? await Task.sleep(nanoseconds: 250_000_000)
                
                // don't unset the poster, so the ui remains disabled while dismissing
                
                config.dismiss(.post)
                
            } catch let error as PostService.Error {
                self.postError = error
                self.poster = nil
            } catch {
                fatalError("unreachable")
            }
        }
    }
    
    func showDrafts() {
        isShowingDraftsList = true
    }
    
    func selectDraft(_ newDraft: Draft) {
        let oldDraft = self.draft
        self.draft = newDraft
        
        if !oldDraft.hasContent {
            DraftsPersistentContainer.shared.viewContext.delete(oldDraft)
        }
        DraftsPersistentContainer.shared.save()
    }
    
    func onDisappear() {
        isDisappearing = true
        if deleteDraftOnDisappear && (!draft.hasContent || didPostSuccessfully || userConfirmedDelete) {
            DraftsPersistentContainer.shared.viewContext.delete(draft)
        }
        DraftsPersistentContainer.shared.save()
    }
    
    func toggleContentWarning() {
        draft.contentWarningEnabled.toggle()
        if draft.contentWarningEnabled {
            contentWarningBecomeFirstResponder = true
        }
    }
    
    @available(iOS 16.0, *)
    @objc private func currentInputModeChanged() {
        guard let mode = currentInput?.textInputMode,
              let code = LanguagePicker.codeFromInputMode(mode),
              !hasChangedLanguageSelection && !draft.hasContent else {
            return
        }
        draft.language = code.identifier
    }
    
    struct ComposeView: View {
        @OptionalObservedObject var poster: PostService?
        @EnvironmentObject var controller: ComposeController
        @EnvironmentObject var draft: Draft
        @StateObject private var keyboardReader = KeyboardReader()
        @State private var globalFrameOutsideList = CGRect.zero
        
        init(poster: PostService?) {
            self.poster = poster
        }
        
        var config: ComposeUIConfig {
            controller.config
        }
        
        var body: some View {
            NavigationView {
                navRoot
            }
            .navigationViewStyle(.stack)
        }
        
        private var navRoot: some View {
            ZStack(alignment: .top) {
                // just using .background doesn't work; for some reason it gets inset immediately after the software keyboard is dismissed
                config.backgroundColor
                    .edgesIgnoringSafeArea(.all)
                
                ScrollViewReader { proxy in
                    mainList
                        .onReceive(controller.scrollToAttachment) { id in
                            proxy.scrollTo(id, anchor: .center)
                        }
                }
                
                if let poster = poster {
                    // can't use SwiftUI.ProgressView because there's no UIProgressView.Style.bar equivalent, see FB8587149
                    WrappedProgressView(value: poster.currentStep, total: poster.totalSteps)
                }
            }
            .safeAreaInset(edge: .bottom, spacing: 0) {
                if controller.showToolbar {
                    VStack(spacing: 0) {
                        ControllerView(controller: { controller.autocompleteController })
                            .transition(.move(edge: .bottom))
                            .animation(.default, value: controller.currentInput?.autocompleteState)
                        
                        ControllerView(controller: { controller.toolbarController })
                    }
                    // on iPadOS15, the toolbar ends up below the keyboard's toolbar without this
                    .padding(.bottom, keyboardInset)
                    .transition(.move(edge: .bottom))
                }
            }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) { cancelButton }
                ToolbarItem(placement: .confirmationAction) { postButton }
            }
            .background(GeometryReader { proxy in
                Color.clear
                    .preference(key: GlobalFrameOutsideListPrefKey.self, value: proxy.frame(in: .global))
                    .onPreferenceChange(GlobalFrameOutsideListPrefKey.self) { newValue in
                        globalFrameOutsideList = newValue
                    }
            })
            .sheet(isPresented: $controller.isShowingDraftsList) {
                ControllerView(controller: { DraftsController(parent: controller, isPresented: $controller.isShowingDraftsList) })
            }
            .alertWithData("Error Posting", data: $controller.postError, actions: { _ in
                Button("OK") {}
            }, message: { error in
                Text(error.localizedDescription)
            })
            .matchedGeometryPresentation(id: Binding(get: { () -> UUID?? in
                let id = controller.focusedAttachment?.0.id
                // this needs to be a double optional, since the type used for for the presentationID in the geom source is a UUID?
                return id.map { Optional.some($0) }
            }, set: {
                if $0 == nil {
                    controller.focusedAttachment = nil
                } else {
                    fatalError()
                }
            }), backgroundColor: .black) {
                ControllerView(controller: {
                    FocusedAttachmentController(
                        parent: controller,
                        attachment: controller.focusedAttachment!.0,
                        thumbnailController: controller.focusedAttachment!.1
                    )
                })
            }
            .onDisappear(perform: controller.onDisappear)
            .navigationTitle(controller.navigationTitle)
            .navigationBarTitleDisplayMode(.inline)
        }
        
        private var mainList: some View {
            List {
                if let id = draft.inReplyToID,
                   let status = controller.fetchStatus(id) {
                    ReplyStatusView(
                        status: status,
                        rowTopInset: 8,
                        globalFrameOutsideList: globalFrameOutsideList
                    )
                    // i don't know why swiftui can't infer this from the status that's passed into the ReplyStatusView changing
                    .id(id)
                    .listRowInsets(EdgeInsets(top: 8, leading: 8, bottom: 4, trailing: 8))
                    .listRowSeparator(.hidden)
                    .listRowBackground(config.backgroundColor)
                }
                
                HeaderView(currentAccount: controller.currentAccount, charsRemaining: controller.charactersRemaining)
                    .listRowInsets(EdgeInsets(top: draft.inReplyToID == nil ? 8 : 4, leading: 8, bottom: 4, trailing: 8))
                    .listRowSeparator(.hidden)
                    .listRowBackground(config.backgroundColor)
                
                if draft.contentWarningEnabled {
                    EmojiTextField(
                        text: $draft.contentWarning,
                        placeholder: "Write your warning here",
                        maxLength: nil,
                        becomeFirstResponder: $controller.contentWarningBecomeFirstResponder,
                        focusNextView: $controller.mainComposeTextViewBecomeFirstResponder
                    )
                        .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
                        .listRowSeparator(.hidden)
                        .listRowBackground(config.backgroundColor)
                }
                
                MainTextView()
                    .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8))
                    .listRowSeparator(.hidden)
                    .listRowBackground(config.backgroundColor)
                
                if let poll = draft.poll {
                    ControllerView(controller: { PollController(parent: controller, poll: poll) })
                        .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
                        .listRowSeparator(.hidden)
                        .listRowBackground(config.backgroundColor)
                }
                
                ControllerView(controller: { controller.attachmentsListController })
                    .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 8, trailing: 8))
                    .listRowBackground(config.backgroundColor)
            }
            .listStyle(.plain)
            .scrollDismissesKeyboardInteractivelyIfAvailable()
            .disabled(controller.isPosting)
        }
        
        private var cancelButton: some View {
            Button(action: controller.cancel) {
                Text("Cancel")
                    // otherwise all Buttons in the nav bar are made semibold
                    .font(.system(size: 17, weight: .regular))
            }
            .disabled(controller.isPosting)
            .confirmationDialog("Are you sure?", isPresented: $controller.isShowingSaveDraftSheet) {
                // edit drafts can't be saved
                if draft.editedStatusID == nil {
                    Button(action: { controller.cancel(deleteDraft: false) }) {
                        Text("Save Draft")
                    }
                    Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
                        Text("Delete Draft")
                    }
                } else {
                    Button(role: .destructive, action: { controller.cancel(deleteDraft: true) }) {
                        Text("Cancel Edit")
                    }
                }
            }
        }
        
        @ViewBuilder
        private var postButton: some View {
            if draft.hasContent || draft.editedStatusID != nil || !controller.config.allowSwitchingDrafts {
                Button(action: controller.postStatus) {
                    Text(draft.editedStatusID == nil ? "Post" : "Edit")
                }
                .keyboardShortcut(.return, modifiers: .command)
                .disabled(!controller.postButtonEnabled)
            } else {
                Button(action: controller.showDrafts) {
                    Text("Drafts")
                }
            }
        }
        
        @available(iOS, obsoleted: 16.0)
        private var keyboardInset: CGFloat {
            if #unavailable(iOS 16.0),
               UIDevice.current.userInterfaceIdiom == .pad,
               keyboardReader.isVisible {
                return ToolbarController.height
            } else {
                return 0
            }
        }
    }
}

private extension View {
    @available(iOS, obsoleted: 16.0)
    @ViewBuilder
    func scrollDismissesKeyboardInteractivelyIfAvailable() -> some View {
        if #available(iOS 16.0, *) {
            self.scrollDismissesKeyboard(.interactively)
        } else {
            self
        }
    }
}

private struct GlobalFrameOutsideListPrefKey: PreferenceKey {
    static var defaultValue: CGRect = .zero
    static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
        value = nextValue()
    }
}

private struct ComposeUIConfigEnvironmentKey: EnvironmentKey {
    static let defaultValue = ComposeUIConfig()
}
extension EnvironmentValues {
    var composeUIConfig: ComposeUIConfig {
        get { self[ComposeUIConfigEnvironmentKey.self] }
        set { self[ComposeUIConfigEnvironmentKey.self] = newValue }
    }
}
