import Foundation
import Observation
import OpenClawKit
import OpenClawProtocol
import SwiftUI

public enum OpenClawQuestionCardStatus: Sendable, Equatable {
    case pending
    case submitting
    case answered
    case answeredElsewhere
    case expired
    case cancelled
    case unavailable
}

@MainActor
@Observable
public final class OpenClawQuestionCardModel: Identifiable {
    public let id: String
    public private(set) var record: QuestionRecord
    public private(set) var isSubmitting = false
    public private(set) var isSkipping = false
    public private(set) var wasAnsweredLocally = false
    public private(set) var errorText: String?
    public private(set) var selectedOptions: [String: Set<String>] = [:]
    public private(set) var otherText: [String: String] = [:]
    public private(set) var isLocallyExpired = false
    public private(set) var isRecoveryUnavailable = false

    public init(record: QuestionRecord) {
        self.id = record.id
        self.record = record
    }

    @discardableResult
    public func apply(record: QuestionRecord) -> Bool {
        let nextRecord = self.preservingKnownAnswers(in: record)
        guard record.id == self.id,
              !((self.record.status != .pending || self.isRecoveryUnavailable) && record.status == .pending),
              !Self.recordsMatch(self.record, nextRecord)
        else { return false }
        self.record = nextRecord
        self.isSubmitting = self.isSubmitting && nextRecord.status == .pending
        self.isSkipping = self.isSkipping && nextRecord.status == .pending
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = false
        return true
    }

    private func preservingKnownAnswers(in record: QuestionRecord) -> QuestionRecord {
        guard record.status == .answered, record.answers == nil, let answers = self.record.answers else {
            return record
        }
        return QuestionRecord(
            id: record.id,
            questions: record.questions,
            agentid: record.agentid,
            sessionkey: record.sessionkey,
            createdatms: record.createdatms,
            expiresatms: record.expiresatms,
            status: record.status,
            answers: answers,
            resolvedby: record.resolvedby)
    }

    public func status(at date: Date = Date()) -> OpenClawQuestionCardStatus {
        if self.isRecoveryUnavailable { return .unavailable }
        switch self.record.status {
        case .answered:
            return self.wasAnsweredLocally ? .answered : .answeredElsewhere
        case .cancelled:
            return .cancelled
        case .expired:
            return .expired
        case .pending:
            if self.isLocallyExpired || date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms) {
                return .expired
            }
            return self.isSubmitting ? .submitting : .pending
        }
    }

    public func remainingSeconds(at date: Date = Date()) -> Int {
        max(0, Int(ceil(Double(self.record.expiresatms) / 1000 - date.timeIntervalSince1970)))
    }

    public func toggleOption(questionID: String, label: String) {
        guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
              question.options.contains(where: { $0.label == label }),
              self.status() == .pending
        else { return }
        var selected = self.selectedOptions[questionID] ?? []
        if question.multiselect == true {
            if selected.contains(label) {
                selected.remove(label)
            } else {
                selected.insert(label)
            }
        } else {
            selected = selected == [label] ? [] : [label]
            if !selected.isEmpty {
                self.otherText[questionID] = ""
            }
        }
        self.selectedOptions[questionID] = selected
        self.errorText = nil
    }

    @discardableResult
    public func toggleOption(questionID: String, optionNumber: Int) -> Bool {
        guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
              self.status() == .pending,
              (1...4).contains(optionNumber),
              question.options.indices.contains(optionNumber - 1)
        else { return false }
        self.toggleOption(questionID: questionID, label: question.options[optionNumber - 1].label)
        return true
    }

    public func setOtherText(questionID: String, value: String) {
        guard let question = self.record.questions.first(where: { $0.questionid == questionID }),
              question.options.isEmpty || question.isother == true,
              self.status() == .pending
        else { return }
        self.otherText[questionID] = value
        if question.multiselect != true, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            self.selectedOptions[questionID] = []
        }
        self.errorText = nil
    }

    public var canSubmit: Bool {
        self.status() == .pending && self.answers() != nil
    }

    public func beginSubmission() -> [String: [String]]? {
        guard let answers = self.answers(), self.status() == .pending else { return nil }
        self.isSubmitting = true
        self.isSkipping = false
        self.errorText = nil
        return answers
    }

    public func beginSkip() -> Bool {
        guard self.status() == .pending else { return false }
        self.isSubmitting = true
        self.isSkipping = true
        self.errorText = nil
        return true
    }

    public func markAnsweredLocally(answers: [String: [String]]) {
        self.wasAnsweredLocally = true
        self.isSubmitting = false
        self.isSkipping = false
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = false
        self.record = QuestionRecord(
            id: self.record.id,
            questions: self.record.questions,
            agentid: self.record.agentid,
            sessionkey: self.record.sessionkey,
            createdatms: self.record.createdatms,
            expiresatms: self.record.expiresatms,
            status: .answered,
            answers: QuestionAnswers(answers: answers.mapValues(AnyCodable.init)),
            resolvedby: self.record.resolvedby)
    }

    public func markSkippedLocally() {
        self.isSubmitting = false
        self.isSkipping = false
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = false
        self.record = QuestionRecord(
            id: self.record.id,
            questions: self.record.questions,
            agentid: self.record.agentid,
            sessionkey: self.record.sessionkey,
            createdatms: self.record.createdatms,
            expiresatms: self.record.expiresatms,
            status: .cancelled,
            resolvedby: self.record.resolvedby)
    }

    // periphery:ignore - Public completion API for package consumers reconciling external answers.
    public func markAnsweredElsewhere() {
        self.isSubmitting = false
        self.isSkipping = false
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = false
        self.record = QuestionRecord(
            id: self.record.id,
            questions: self.record.questions,
            agentid: self.record.agentid,
            sessionkey: self.record.sessionkey,
            createdatms: self.record.createdatms,
            expiresatms: self.record.expiresatms,
            status: .answered,
            resolvedby: self.record.resolvedby)
    }

    @discardableResult
    public func markRecoveryUnavailable() -> Bool {
        guard !self.isRecoveryUnavailable else { return false }
        self.isSubmitting = false
        self.isSkipping = false
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = true
        return true
    }

    public func apply(resolved: OpenClawQuestionResolvedEvent) {
        guard resolved.id == self.id else { return }
        self.isSubmitting = false
        self.isSkipping = false
        self.isLocallyExpired = false
        self.isRecoveryUnavailable = false
        self.record = QuestionRecord(
            id: self.record.id,
            questions: self.record.questions,
            agentid: self.record.agentid,
            sessionkey: self.record.sessionkey,
            createdatms: self.record.createdatms,
            expiresatms: self.record.expiresatms,
            status: resolved.status,
            answers: resolved.answers ?? self.record.answers,
            resolvedby: self.record.resolvedby)
    }

    public func failSubmission(_ message: String) {
        self.isSubmitting = false
        self.isSkipping = false
        self.errorText = message
    }

    func observeLocalExpiry(at date: Date) -> Bool {
        guard self.record.status == .pending, !self.isLocallyExpired,
              date.timeIntervalSince1970 * 1000 >= Double(self.record.expiresatms)
        else { return false }
        self.isLocallyExpired = true
        self.isSubmitting = false
        self.isSkipping = false
        return true
    }

    func localExpiryDelay(at date: Date) -> TimeInterval? {
        guard self.record.status == .pending, !self.isLocallyExpired else { return nil }
        return max(0, Double(self.record.expiresatms) / 1000 - date.timeIntervalSince1970)
    }

    public func terminalSummaryText(for question: Question) -> String {
        switch self.status() {
        case .answered:
            self.answerValues(questionID: question.questionid)?.joined(separator: ", ") ?? String(localized: "Answered")
        case .answeredElsewhere:
            self.answerValues(questionID: question.questionid)?.joined(separator: ", ")
                ?? String(localized: "Answered elsewhere")
        case .cancelled:
            String(localized: "Skipped")
        case .expired:
            String(localized: "Expired")
        case .unavailable:
            String(localized: "Unavailable")
        case .pending, .submitting:
            String(localized: "Pending")
        }
    }

    private func answers() -> [String: [String]]? {
        var result: [String: [String]] = [:]
        for question in self.record.questions {
            let selected = self.selectedOptions[question.questionid] ?? []
            var values = question.options.compactMap { selected.contains($0.label) ? $0.label : nil }
            if let other = self.otherText[question.questionid]?.trimmingCharacters(in: .whitespacesAndNewlines),
               !other.isEmpty
            {
                values.append(other)
            }
            guard !values.isEmpty else { return nil }
            result[question.questionid] = values
        }
        return result
    }

    private func answerValues(questionID: String) -> [String]? {
        guard let answer = self.record.answers?.answers[questionID],
              let data = try? JSONEncoder().encode(answer),
              let decoded = try? JSONDecoder().decode([String].self, from: data),
              !decoded.isEmpty
        else { return nil }
        return decoded
    }

    private static func recordsMatch(_ lhs: QuestionRecord, _ rhs: QuestionRecord) -> Bool {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.sortedKeys]
        return (try? encoder.encode(lhs)) == (try? encoder.encode(rhs))
    }
}

struct OpenClawQuestionCard: View {
    @Bindable private var model: OpenClawQuestionCardModel
    private let onSubmit: @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void
    private let onSkip: (@MainActor @Sendable (OpenClawQuestionCardModel) async -> Void)?
    #if os(macOS)
    @FocusState private var focusedQuestionID: String?
    #endif

    init(
        model: OpenClawQuestionCardModel,
        onSubmit: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void,
        onSkip: @escaping @MainActor @Sendable (OpenClawQuestionCardModel) async -> Void)
    {
        self.model = model
        self.onSubmit = onSubmit
        self.onSkip = onSkip
    }

    var body: some View {
        let status = self.model.status()
        if status == .pending || status == .submitting {
            self.pendingCard
        } else {
            self.terminalSummary
        }
    }

    private var pendingCard: some View {
        TimelineView(.periodic(from: .now, by: 1)) { context in
            VStack(alignment: .leading, spacing: 14) {
                ForEach(self.model.record.questions, id: \.questionid) { question in
                    self.questionSection(question, now: context.date)
                }
                self.footer(now: context.date)
            }
            .padding(16)
            .background(OpenClawChatTheme.subtleCard, in: RoundedRectangle(cornerRadius: 16))
            .overlay(RoundedRectangle(cornerRadius: 16).stroke(.secondary.opacity(0.2)))
        }
    }

    private var terminalSummary: some View {
        VStack(alignment: .leading, spacing: 5) {
            ForEach(self.model.record.questions, id: \.questionid) { question in
                HStack(alignment: .firstTextBaseline, spacing: 5) {
                    Text(verbatim: "\(question.header):")
                        .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
                    Text(self.model.terminalSummaryText(for: question))
                        .font(OpenClawChatTypography.body(size: 14, weight: .regular, relativeTo: .callout))
                        .foregroundStyle(.secondary)
                }
                .accessibilityElement(children: .combine)
            }
        }
        .padding(.horizontal, 12)
        .padding(.vertical, 9)
        .background(OpenClawChatTheme.subtleCard, in: RoundedRectangle(cornerRadius: 12))
        .accessibilityElement(children: .contain)
        .accessibilityLabel("Question summary")
    }

    private func questionSection(_ question: Question, now: Date) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text(question.header.uppercased())
                .font(OpenClawChatTypography.captionSemiBold)
                .foregroundStyle(OpenClawChatTheme.accent)
            Text(question.question)
                .font(OpenClawChatTypography.body)
            ForEach(question.options, id: \.label) { option in
                self.optionRow(question: question, option: option, now: now)
            }
            if question.options.isEmpty || question.isother == true {
                TextField(
                    "Other answer",
                    text: Binding(
                        get: { self.model.otherText[question.questionid] ?? "" },
                        set: { self.model.setOtherText(questionID: question.questionid, value: $0) }),
                    axis: .vertical)
                    .font(OpenClawChatTypography.body)
                    .textFieldStyle(.roundedBorder)
                    .disabled(self.model.status(at: now) != .pending)
                    .accessibilityLabel("Other answer")
            }
        }
        #if os(macOS)
        .focusable()
        .focused(self.$focusedQuestionID, equals: question.questionid)
        .onKeyPress(characters: .decimalDigits) { keyPress in
            guard self.focusedQuestionID == question.questionid else { return .ignored }
            return self.handleNumberKey(keyPress, question: question, now: now)
        }
        .onKeyPress(.return) {
            guard self.focusedQuestionID == question.questionid,
                  self.model.status(at: now) == .pending,
                  self.model.canSubmit
            else { return .ignored }
            Task { await self.onSubmit(self.model) }
            return .handled
        }
        #endif
    }

    private func optionRow(question: Question, option: QuestionOption, now: Date) -> some View {
        let selected = self.model.selectedOptions[question.questionid]?.contains(option.label) == true
        return Button {
            #if os(macOS)
            self.focusedQuestionID = question.questionid
            #endif
            self.model.toggleOption(questionID: question.questionid, label: option.label)
        } label: {
            HStack(alignment: .top, spacing: 10) {
                Image(systemName: selected
                    ? (question.multiselect == true ? "checkmark.square.fill" : "largecircle.fill.circle")
                    : (question.multiselect == true ? "square" : "circle"))
                    .foregroundStyle(selected ? OpenClawChatTheme.accent : .secondary)
                VStack(alignment: .leading, spacing: 2) {
                    Text(option.label).font(OpenClawChatTypography.body)
                    if let description = option.description, !description.isEmpty {
                        Text(description)
                            .font(OpenClawChatTypography.caption)
                            .foregroundStyle(.secondary)
                    }
                }
                Spacer(minLength: 0)
            }
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .disabled(self.model.status(at: now) != .pending)
        .accessibilityLabel(option.label)
        .accessibilityValue(selected ? "Selected" : "Not selected")
    }

    @ViewBuilder
    private func footer(now: Date) -> some View {
        let status = self.model.status(at: now)
        if status == .pending || status == .submitting {
            HStack {
                Text(self.countdownText(now: now))
                    .font(OpenClawChatTypography.caption)
                    .foregroundStyle(.secondary)
                Spacer()
                if let onSkip = self.onSkip {
                    Button {
                        Task { await onSkip(self.model) }
                    } label: {
                        if self.model.isSkipping {
                            Text("Skipping…")
                                .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
                        } else {
                            Text("Skip")
                                .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
                        }
                    }
                    .buttonStyle(.bordered)
                    .disabled(status == .submitting)
                }
                Button {
                    Task { await self.onSubmit(self.model) }
                } label: {
                    if status == .submitting, !self.model.isSkipping {
                        Text("Submitting…")
                            .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
                    } else {
                        Text("Submit")
                            .font(OpenClawChatTypography.body(size: 14, weight: .semibold, relativeTo: .callout))
                    }
                }
                .buttonStyle(.borderedProminent)
                .disabled(!self.model.canSubmit || status == .submitting)
            }
            if let errorText = self.model.errorText {
                Text(errorText)
                    .font(OpenClawChatTypography.caption)
                    .foregroundStyle(OpenClawChatTheme.danger)
            }
        }
    }

    private func countdownText(now: Date) -> String {
        let seconds = self.model.remainingSeconds(at: now)
        return String(format: "%d:%02d", seconds / 60, seconds % 60)
    }

    #if os(macOS)
    private func handleNumberKey(
        _ keyPress: KeyPress,
        question: Question,
        now: Date) -> KeyPress.Result
    {
        guard self.model.status(at: now) == .pending,
              let digit = keyPress.characters.first?.wholeNumberValue,
              self.model.toggleOption(questionID: question.questionid, optionNumber: digit)
        else { return .ignored }
        return .handled
    }
    #endif
}

@MainActor
struct OpenClawQuestionCards: View {
    let viewModel: OpenClawChatViewModel

    var body: some View {
        ForEach(self.viewModel.visibleQuestionCards) { card in
            OpenClawQuestionCard(model: card) { [weak viewModel = self.viewModel] model in
                await viewModel?.submitQuestion(model)
            } onSkip: { [weak viewModel = self.viewModel] model in
                await viewModel?.skipQuestion(model)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
        }
    }
}

private enum QuestionLookupResult {
    case record(QuestionRecord)
    case notFound
    case failed
}

private struct QuestionRefreshApplyResult {
    let complete: Bool
    let changed: Bool
}

extension OpenClawChatViewModel {
    public var visibleQuestionCards: [OpenClawQuestionCardModel] {
        self.questionCards.filter { card in
            guard let key = card.record.sessionkey else { return true }
            return self.matchesCurrentSessionKey(
                incoming: key,
                agentId: card.record.agentid,
                current: self.sessionKey)
        }
    }

    func refreshQuestions() async {
        self.questionRefreshGeneration &+= 1
        let refreshGeneration = self.questionRefreshGeneration
        self.questionRefreshRetryTask?.cancel()
        self.questionRefreshRetryTask = nil
        await self.refreshQuestions(generation: refreshGeneration, retryIndex: 0)
    }

    private func refreshQuestions(generation refreshGeneration: UInt64, retryIndex: Int) async {
        guard refreshGeneration == self.questionRefreshGeneration else { return }
        let stateRevision = self.questionStateRevision
        do {
            let records = try await self.transport.listQuestions()
            guard self.questionRefreshSnapshotIsCurrent(
                generation: refreshGeneration,
                stateRevision: stateRevision)
            else { return }
            let listedIDs = Set(records.map(\.id))
            let missingPending = self.questionCards.filter { model in
                model.record.status == .pending &&
                    !model.isRecoveryUnavailable &&
                    !listedIDs.contains(model.id)
            }
            let lookups = await self.fetchMissingQuestionLookups(missingPending)
            guard self.questionRefreshSnapshotIsCurrent(
                generation: refreshGeneration,
                stateRevision: stateRevision)
            else { return }
            let result = self.applyQuestionRefresh(records: records, lookups: lookups)
            if result.complete {
                self.questionRefreshRetryTask = nil
            } else {
                self.scheduleQuestionRefreshRetry(
                    generation: refreshGeneration,
                    retryIndex: result.changed ? 0 : retryIndex)
            }
        } catch let error as GatewayResponseError where Self.questionListIsUnavailable(error) {
            guard self.questionRefreshSnapshotIsCurrent(
                generation: refreshGeneration,
                stateRevision: stateRevision)
            else { return }
            self.clearPendingQuestionsForUnavailableList()
        } catch {
            guard self.questionRefreshSnapshotIsCurrent(
                generation: refreshGeneration,
                stateRevision: stateRevision)
            else { return }
            self.scheduleQuestionRefreshRetry(
                generation: refreshGeneration,
                retryIndex: retryIndex)
        }
    }

    private func fetchMissingQuestionLookups(
        _ models: [OpenClawQuestionCardModel]) async
        -> [(OpenClawQuestionCardModel, QuestionLookupResult)]
    {
        // The gateway enumerates pending questions only; terminal records remain addressable
        // briefly by known ID. Cards observed by this view model persist without timed eviction,
        // but session transcripts do not contain enough question data to recreate them on launch.
        var lookups: [(OpenClawQuestionCardModel, QuestionLookupResult)] = []
        for model in models {
            do {
                let record = try await self.transport.getQuestion(id: model.id)
                lookups.append((model, .record(record)))
            } catch let error as GatewayResponseError where Self.questionIsNotFound(error) {
                lookups.append((model, .notFound))
            } catch {
                lookups.append((model, .failed))
            }
        }
        return lookups
    }

    private func applyQuestionRefresh(
        records: [QuestionRecord],
        lookups: [(OpenClawQuestionCardModel, QuestionLookupResult)]) -> QuestionRefreshApplyResult
    {
        var changed = false
        for record in records {
            if let model = self.questionCards.first(where: { $0.id == record.id }) {
                changed = model.apply(record: record) || changed
            } else {
                self.questionCards.append(OpenClawQuestionCardModel(record: record))
                changed = true
            }
        }
        var complete = true
        for (model, result) in lookups {
            guard self.questionCards.contains(where: { $0 === model }) else { continue }
            switch result {
            case let .record(record):
                changed = model.apply(record: record) || changed
            case .notFound:
                // The terminal tombstone has aged out, so the question is no longer actionable,
                // but its answered/cancelled/expired outcome cannot be reconstructed.
                changed = model.markRecoveryUnavailable() || changed
            case .failed:
                complete = false
            }
        }
        self.syncQuestionExpirations()
        if changed {
            self.questionStateRevision &+= 1
            self.markTimelineChanged()
        }
        return QuestionRefreshApplyResult(complete: complete, changed: changed)
    }

    private func clearPendingQuestionsForUnavailableList() {
        let previousCount = self.questionCards.count
        self.questionCards.removeAll {
            let status = $0.status()
            return status == .pending || status == .submitting
        }
        self.syncQuestionExpirations()
        if self.questionCards.count != previousCount {
            self.questionStateRevision &+= 1
            self.markTimelineChanged()
        }
    }

    private func questionRefreshSnapshotIsCurrent(generation: UInt64, stateRevision: UInt64) -> Bool {
        guard generation == self.questionRefreshGeneration else { return false }
        guard stateRevision == self.questionStateRevision else {
            self.restartQuestionRefreshAfterStateChange(generation: generation)
            return false
        }
        return true
    }

    private nonisolated static func questionListIsUnavailable(_ error: GatewayResponseError) -> Bool {
        if error.missingScope == "operator.questions" { return true }
        return error.code == "INVALID_REQUEST" && error.message == "unknown method: question.list"
    }

    private nonisolated static func questionIsNotFound(_ error: GatewayResponseError) -> Bool {
        error.detailsReason == "QUESTION_NOT_FOUND"
    }

    private func restartQuestionRefreshAfterStateChange(generation: UInt64) {
        // Local question mutations invalidate the whole lookup snapshot, not one transport attempt.
        // Restart the bounded budget so a late mutation cannot consume the last reconciliation slot.
        self.scheduleQuestionRefreshRetry(generation: generation, retryIndex: 0)
    }

    private func scheduleQuestionRefreshRetry(generation: UInt64, retryIndex: Int) {
        guard generation == self.questionRefreshGeneration else { return }
        guard self.questionRefreshRetryDelaysMs.indices.contains(retryIndex) else {
            self.questionRefreshRetryTask = nil
            return
        }
        let delayMs = self.questionRefreshRetryDelaysMs[retryIndex]
        let stateRevision = self.questionStateRevision
        self.questionRefreshRetryTask?.cancel()
        self.questionRefreshRetryTask = Task { [weak self] in
            try? await Task.sleep(for: .milliseconds(delayMs))
            guard !Task.isCancelled, let self,
                  generation == self.questionRefreshGeneration
            else { return }
            // A mutation during backoff invalidates the reconciliation attempt, not the retry budget.
            let nextRetryIndex = stateRevision == self.questionStateRevision ? retryIndex + 1 : 0
            await self.refreshQuestions(generation: generation, retryIndex: nextRetryIndex)
        }
    }

    func upsertQuestion(_ record: QuestionRecord) {
        if let model = self.questionCards.first(where: { $0.id == record.id }) {
            guard model.apply(record: record) else { return }
        } else {
            self.questionCards.append(OpenClawQuestionCardModel(record: record))
        }
        self.questionStateRevision &+= 1
        self.syncQuestionExpirations()
        self.markTimelineChanged()
    }

    func resolveQuestionEvent(_ event: OpenClawQuestionResolvedEvent) {
        self.questionCards.first(where: { $0.id == event.id })?.apply(resolved: event)
        self.questionStateRevision &+= 1
        self.syncQuestionExpirations()
        self.markTimelineChanged()
    }

    func reconcileQuestionsAfterEvent() {
        // Invalidate a list snapshot captured before this event, then fetch the
        // authoritative set so other pending cards from that snapshot are not lost.
        self.questionRefreshGeneration &+= 1
        self.questionRefreshRetryTask?.cancel()
        self.questionRefreshRetryTask = nil
        Task { [weak self] in await self?.refreshQuestions() }
    }

    func submitQuestion(_ model: OpenClawQuestionCardModel) async {
        guard let answers = model.beginSubmission() else { return }
        self.questionStateRevision &+= 1
        do {
            try await self.transport.resolveQuestion(id: model.id, answers: answers)
            model.markAnsweredLocally(answers: answers)
            self.questionStateRevision &+= 1
            self.syncQuestionExpirations()
            self.markTimelineChanged()
        } catch {
            model.failSubmission(error.localizedDescription)
            self.questionStateRevision &+= 1
        }
    }

    func skipQuestion(_ model: OpenClawQuestionCardModel) async {
        guard model.beginSkip() else { return }
        self.questionStateRevision &+= 1
        do {
            try await self.transport.cancelQuestion(id: model.id)
            model.markSkippedLocally()
            self.questionStateRevision &+= 1
            self.syncQuestionExpirations()
            self.markTimelineChanged()
        } catch {
            model.failSubmission(error.localizedDescription)
            self.questionStateRevision &+= 1
        }
    }

    func expireQuestionIfNeeded(
        _ model: OpenClawQuestionCardModel,
        at date: Date = Date())
    {
        guard self.questionCards.first(where: { $0.id == model.id }) === model else { return }
        if model.observeLocalExpiry(at: date) {
            self.questionStateRevision &+= 1
            self.syncQuestionExpirations(at: date)
            self.markTimelineChanged()
            Task { [weak self] in await self?.refreshQuestions() }
        } else {
            self.syncQuestionExpirations(at: date)
        }
    }

    private func syncQuestionExpirations(at date: Date = Date()) {
        let modelsByID = Dictionary(uniqueKeysWithValues: self.questionCards.map { ($0.id, $0) })
        let cancelledIDs = self.questionExpiryTasks.keys.filter { modelsByID[$0] == nil }
        for id in cancelledIDs {
            self.questionExpiryTasks.removeValue(forKey: id)?.cancel()
            self.questionExpiryDeadlines.removeValue(forKey: id)
        }
        for model in self.questionCards {
            guard let delay = model.localExpiryDelay(at: date) else {
                self.questionExpiryTasks.removeValue(forKey: model.id)?.cancel()
                self.questionExpiryDeadlines.removeValue(forKey: model.id)
                continue
            }
            let deadline = date.addingTimeInterval(delay)
            if let scheduled = self.questionExpiryDeadlines[model.id],
               abs(scheduled.timeIntervalSince(deadline)) < 0.01
            {
                continue
            }
            self.questionExpiryTasks.removeValue(forKey: model.id)?.cancel()
            self.questionExpiryDeadlines[model.id] = deadline
            self.questionExpiryTasks[model.id] = Task { [weak self, weak model] in
                try? await Task.sleep(for: .seconds(delay))
                guard !Task.isCancelled, let self, let model else { return }
                self.questionExpiryTasks.removeValue(forKey: model.id)
                self.questionExpiryDeadlines.removeValue(forKey: model.id)
                self.expireQuestionIfNeeded(model)
            }
        }
    }
}
