Sources/Miri/GestureSettings.swift

import AppKit
import MiriCore
import SwiftUI

/// Settings > Gestures adds the reverse toggle and the gesture change report to the shared strip swipe controls.
struct GestureSettings: View {
    let model: AppModel
    @State private var confirmingReport = false

    var body: some View {
        Form {
            ConfigurationIssueBanner(model: model)
            StripSwipeSections(
                model: model,
                choiceFooter:
                    "Swipe left or right on the trackpad to slide the windows. The strip follows your fingers unless you reverse it.",
                testFooter:
                    "Miri turns the swipe on only after you test it and confirm that the strip followed your fingers. Keyboard shortcuts work either way. Miri never changes macOS settings."
            ) {
                Toggle("Reverse swipe direction", isOn: setting(\.reverseSwipe))
            } testAccessory: {
                Button("Report a macOS Gesture Change") { confirmingReport = true }
                    .disabled(model.gestureVerification == nil)
            }
        }
        .formStyle(.grouped)
        .onAppear { model.refreshSetup() }
        .confirmationDialog("Report a macOS gesture change?", isPresented: $confirmingReport) {
            Button("Report Change") { model.reportGestureChange() }
        } message: {
            Text("Miri turns the strip swipe off until you test it again.")
        }
    }

    private func setting<Value>(_ key: WritableKeyPath<MiriSettings, Value>) -> Binding<Value> {
        Binding(get: { model.settings[keyPath: key] }, set: { value in model.update { $0[keyPath: key] = value } })
    }
}

/// The finger count choice, the swipe status, and the gesture test, shared by Settings and initial setup.
struct StripSwipeSections<ChoiceAccessory: View, TestAccessory: View>: View {
    let model: AppModel
    var header: LocalizedStringKey? = nil
    let choiceFooter: LocalizedStringKey
    let testFooter: LocalizedStringKey
    @ViewBuilder let choiceAccessory: ChoiceAccessory
    @ViewBuilder let testAccessory: TestAccessory
    @State private var testing = false

    var body: some View {
        Section {
            Picker(
                "Strip swipe",
                selection: Binding(
                    get: { model.settings.stripGesture }, set: { value in model.update { $0.stripGesture = value } })
            ) {
                Text("Off").tag(StripGesture.off)
                Text("Three fingers").tag(StripGesture.three)
                Text("Four fingers (recommended)").tag(StripGesture.four)
            }
            choiceAccessory
        } header: {
            if let header { Text(header) }
        } footer: {
            Text(choiceFooter).foregroundStyle(.secondary)
        }
        Section {
            GestureStatusRow(model: model)
            HStack {
                Button("Test Gesture…") { testing = true }
                    .disabled(model.gestureTestBlocker != nil)
                Spacer()
                testAccessory
            }
            .sheet(isPresented: $testing) { GestureTestSheet(model: model) }
            if let blocker = model.gestureTestBlocker, model.settings.stripGesture != .off {
                Text(blocker).foregroundStyle(.secondary)
            }
        } footer: {
            Text(testFooter).foregroundStyle(.secondary)
        }
    }
}

extension StripSwipeSections where ChoiceAccessory == EmptyView, TestAccessory == EmptyView {
    init(
        model: AppModel, header: LocalizedStringKey? = nil, choiceFooter: LocalizedStringKey,
        testFooter: LocalizedStringKey
    ) {
        self.init(
            model: model, header: header, choiceFooter: choiceFooter, testFooter: testFooter,
            choiceAccessory: { EmptyView() },
            testAccessory: { EmptyView() })
    }
}

struct GestureStatusRow: View {
    let model: AppModel

    var body: some View {
        LabeledContent("Status") {
            VStack(alignment: .trailing, spacing: 3) {
                LiveText(summary)
                switch model.gestureState {
                case .needsTest(_, detectionUnavailable: true):
                    Text("Unable to verify").foregroundStyle(.secondary)
                case .conflict(let message):
                    LiveText(message).foregroundStyle(.secondary).multilineTextAlignment(.trailing)
                        .fixedSize(horizontal: false, vertical: true)
                    if let url = conflictCheck?.settingsURL {
                        Button("Open System Settings") { NSWorkspace.shared.open(url) }
                    }
                case .off, .needsTest, .enabled: EmptyView()
                }
            }
        }
    }

    private var summary: String {
        switch model.gestureState {
        case .off: "Off"
        case .enabled(let date): "On, verified by user test on \(date.formatted(date: .abbreviated, time: .omitted))"
        case .needsTest(let reason, _): "Off until tested: \(reason.explanation)"
        case .conflict: "Off: macOS uses this swipe"
        }
    }

    private var conflictCheck: SetupCheck? {
        model.settings.stripGesture.fingers.flatMap(model.gestureCheck)
    }
}

extension MiriCore.GestureState.TestReason {
    var explanation: String {
        switch self {
        case .neverTested: "not tested yet"
        case .fingersChanged: "the finger count changed"
        case .directionChanged: "the direction changed"
        case .macOSChanged: "macOS was updated"
        case .engineChanged: "Miri's engine was updated"
        case .gestureSettingsChanged: "macOS trackpad gestures changed"
        }
    }
}

/// Guides one gesture test, from swiping to the user's confirmation.
struct GestureTestSheet: View {
    let model: AppModel
    @State private var test: GestureTest
    @Environment(\.dismiss) private var dismiss

    init(model: AppModel) {
        self.model = model
        _test = State(initialValue: GestureTest(model: model))
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            Text("Test the Strip Swipe").font(.headline)
            content
        }
        .padding(20)
        .frame(width: 420, alignment: .leading)
        .onAppear { test.start() }
        .onDisappear { test.cancel() }
        // Closing Settings hides its window without removing this view, so onDisappear alone can miss it.
        .onReceive(NotificationCenter.default.publisher(for: NSWindow.willCloseNotification)) { _ in test.cancel() }
    }

    @ViewBuilder private var content: some View {
        switch test.phase {
        case .idle, .watching:
            Text("Swipe left and right with \(fingerWord) fingers on the trackpad.")
            Text("Keep at least two windows on this display so the strip can move.")
                .foregroundStyle(.secondary)
            HStack(spacing: 8) {
                if case .watching(movementSeen: true) = test.phase {
                    Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
                    Text("The strip moved. Keep swiping left and right.")
                } else {
                    ProgressView().controlSize(.small)
                    Text("Waiting for the strip to move…")
                }
            }
            buttons { Button("Cancel", role: .cancel) { dismiss() } }
        case .passedAwaitingConfirmation:
            Text("Did the strip follow your fingers?")
            Text("Miri saw the strip move both ways and no macOS action. Only your answer turns the swipe on.")
                .foregroundStyle(.secondary)
                .fixedSize(horizontal: false, vertical: true)
            buttons {
                Button("Cancel", role: .cancel) { dismiss() }
                Button("It Did Not Work") { test.deny() }
                Button("The Strip Followed My Fingers") {
                    test.confirm()
                    dismiss()
                }
                .keyboardShortcut(.defaultAction)
            }
        case .failed(let reason):
            Label(reason, systemImage: "exclamationmark.triangle.fill")
            Text("The swipe stays off. Keyboard shortcuts keep working.").foregroundStyle(.secondary)
            retryButtons
        case .inconclusive:
            Label("Miri did not see the strip move both ways within 20 seconds.", systemImage: "questionmark.circle")
            Text("The swipe stays off. Keyboard shortcuts keep working.").foregroundStyle(.secondary)
            retryButtons
        }
    }

    private var retryButtons: some View {
        buttons {
            Button("Done", role: .cancel) { dismiss() }
            Button("Try Again") { test.start() }.keyboardShortcut(.defaultAction)
        }
    }

    private func buttons(@ViewBuilder _ content: () -> some View) -> some View {
        HStack {
            Spacer()
            content()
        }
    }

    private var fingerWord: String {
        switch test.fingers ?? model.settings.stripGesture.fingers {
        case .three?: "three"
        case .four?, nil: "four"
        }
    }
}