import Foundation
import OSLog

private let chatSessionActionsLogger = Logger(
    subsystem: "ai.openclaw",
    category: "OpenClawChat")

extension OpenClawChatViewModel {
    private enum SessionBranchesRefreshPurpose {
        case readOnly
        case reconcile
        case finalizeMutation
    }

    public func refreshSessions(limit: Int? = nil) {
        let context = self.currentSessionSnapshot()
        Task { await self.fetchSessions(limit: limit, sessionSnapshot: context) }
    }

    /// Returns true only when a session switch happened (create or reset
    /// fallback); callers keep UI like the new-session popover open on failure.
    @discardableResult
    func performStartNewSession(
        agentID: String? = nil,
        worktree: Bool,
        worktreeBaseRef: String? = nil,
        routeLease: OpenClawChatNewSessionRouteLease? = nil) async -> Bool
    {
        guard !self.blocksAttachmentOwnerChange else {
            self.errorText = String(
                localized: "Remove attachments or wait for delivery to resolve before starting a new chat.")
            return false
        }
        let normalizedAgentID = agentID?
            .trimmingCharacters(in: .whitespacesAndNewlines)
            .lowercased()
        let requestedAgentID = normalizedAgentID?.isEmpty == false ? normalizedAgentID : nil
        let requested = self.generatedNewSessionKey(agentID: requestedAgentID)
        // Only authoritative identities decide agent ownership; scanning the roster
        // could adopt an unrelated agent and hand sessions.create a cross-agent parent.
        let currentAgentID = (
            OpenClawChatSessionKey.agentID(from: self.sessionKey) ??
                self.activeAgentId ??
                OpenClawChatSessionKey.agentID(from: self.resolvedMainSessionKey))?
            .lowercased()
        let parentSessionKey = requestedAgentID == nil || requestedAgentID == currentAgentID
            ? self.sessionKey
            : nil
        let next: String
        do {
            let created = if let routeLease {
                try await routeLease.createSession(
                    key: requested,
                    label: nil,
                    agentID: requestedAgentID,
                    parentSessionKey: parentSessionKey,
                    worktree: worktree ? true : nil,
                    worktreeBaseRef: worktree ? worktreeBaseRef : nil)
            } else {
                try await self.transport.createSession(
                    key: requested,
                    label: nil,
                    agentID: requestedAgentID,
                    parentSessionKey: parentSessionKey,
                    worktree: worktree ? true : nil,
                    worktreeBaseRef: worktree ? worktreeBaseRef : nil)
            }
            let createdKey = created.key.trimmingCharacters(in: .whitespacesAndNewlines)
            next = createdKey.isEmpty ? requested : createdKey
        } catch {
            if Self.isUnsupportedCreateSessionError(error) {
                // Reset only mimics a plain new chat; agent/worktree selections were
                // not honored, so advanced requests surface the error instead of
                // silently resetting the current session.
                guard requestedAgentID == nil, !worktree else {
                    chatUILogger.error("sessions.create unsupported; advanced options not honored")
                    self.errorText = error.localizedDescription
                    return false
                }
                chatUILogger.info("sessions.create unsupported; falling back to sessions.reset")
                await self.performReset()
                return true
            }
            chatUILogger.error("sessions.create failed \(error.localizedDescription, privacy: .public)")
            self.errorText = error.localizedDescription
            return false
        }
        guard !self.blocksAttachmentOwnerChange else {
            self.errorText = String(
                localized: "Remove attachments or wait for delivery to resolve before starting a new chat.")
            return false
        }
        self.adoptCreatedSession(next)
        return true
    }

    static func isUnsupportedCreateSessionError(_ error: Error) -> Bool {
        let nsError = error as NSError
        return nsError.domain == "OpenClawChatTransport"
            && nsError.localizedDescription == "sessions.create not supported by this transport"
    }

    @discardableResult
    public func startNewSession(
        agentID: String? = nil,
        worktree: Bool = false,
        worktreeBaseRef: String? = nil) async -> Bool
    {
        await self.performStartNewSession(
            agentID: agentID,
            worktree: worktree,
            worktreeBaseRef: worktreeBaseRef)
    }

    @discardableResult
    func startNewSession(
        agentID: String,
        worktree: Bool,
        worktreeBaseRef: String?,
        using routeLease: OpenClawChatNewSessionRouteLease) async -> Bool
    {
        await self.performStartNewSession(
            agentID: agentID,
            worktree: worktree,
            worktreeBaseRef: worktreeBaseRef,
            routeLease: routeLease)
    }

    func newSessionRouteLease() async throws -> OpenClawChatNewSessionRouteLease {
        guard let routeLease = await self.transport.acquireNewSessionRouteLease() else {
            throw OpenClawChatTransportSendError.notDispatched
        }
        return routeLease
    }

    public func fetchSessionGroups() async throws -> [OpenClawChatSessionGroup] {
        let routeLease = try await self.sessionGroupsRouteLease()
        return try await self.fetchSessionGroups(using: routeLease)
    }

    func sessionGroupsRouteLease() async throws -> OpenClawChatSessionGroupsRouteLease {
        guard let routeLease = await self.transport.acquireSessionGroupsRouteLease() else {
            throw OpenClawChatTransportSendError.notDispatched
        }
        return routeLease
    }

    func fetchSessionGroups(
        using routeLease: OpenClawChatSessionGroupsRouteLease) async throws -> [OpenClawChatSessionGroup]
    {
        let response = try await routeLease.listGroups()
        return (response?.groups ?? []).sorted { lhs, rhs in
            lhs.position == rhs.position ? lhs.name < rhs.name : lhs.position < rhs.position
        }
    }

    @discardableResult
    func createSessionGroup(
        named rawName: String,
        using routeLease: OpenClawChatSessionGroupsRouteLease) async throws -> [OpenClawChatSessionGroup]
    {
        let name = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !name.isEmpty else { return try await self.fetchSessionGroups(using: routeLease) }
        // Read-modify-write matches web group creation (app-sidebar-session-groups,
        // custom-groups); the gateway has no atomic add/CAS. A concurrent edit can
        // lose catalog names/order only — session categories are untouched by
        // sessions.groups.put, so memberships survive. Accepted tradeoff until the
        // gateway grows a revisioned groups API.
        let current = try await self.fetchSessionGroups(using: routeLease)
        let response = try await routeLease.putGroups(names: current.map(\.name) + [name])
        self.sessionGroupsRevision += 1
        return response.groups
    }

    @discardableResult
    func renameSessionGroup(
        _ name: String,
        to rawName: String,
        using routeLease: OpenClawChatSessionGroupsRouteLease) async throws -> [OpenClawChatSessionGroup]
    {
        let nextName = rawName.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !nextName.isEmpty else { return try await self.fetchSessionGroups(using: routeLease) }
        let response = try await routeLease.renameGroup(name: name, to: nextName)
        self.sessionGroupsRevision += 1
        self.refreshSessions(limit: Self.sessionListFetchLimit)
        return response.groups
    }

    @discardableResult
    func deleteSessionGroup(
        _ name: String,
        using routeLease: OpenClawChatSessionGroupsRouteLease) async throws -> [OpenClawChatSessionGroup]
    {
        let response = try await routeLease.deleteGroup(name: name)
        self.sessionGroupsRevision += 1
        self.refreshSessions(limit: Self.sessionListFetchLimit)
        return response.groups
    }

    public func setSessionGroup(key: String, group: String?) async throws {
        let normalized = group?.trimmingCharacters(in: .whitespacesAndNewlines)
        let nextGroup = normalized?.isEmpty == false ? normalized : nil
        let routeLease = await self.transport.acquireSessionMutationRouteLease()
        guard let routeLease else { throw OpenClawChatTransportSendError.notDispatched }
        try await routeLease.patchSession(
            key: key,
            label: nil,
            category: .some(nextGroup),
            pinned: nil,
            archived: nil,
            unread: nil)
        if let index = self.sessions.firstIndex(where: { $0.key == key }) {
            self.sessions[index].category = nextGroup
        }
        self.refreshSessions(limit: Self.sessionListFetchLimit)
    }

    func performSessionBatch(
        sessions selectedSessions: [OpenClawChatSessionEntry],
        action: ChatSessionBatchAction) async -> ChatSessionBatchResult
    {
        let orderedKeys = selectedSessions.map(\.key)
        let entries = Dictionary(uniqueKeysWithValues: selectedSessions.map { ($0.key, $0) })
        let mainSessionKey = self.resolvedMainSessionKey
        let attachmentBlockedKeys = self.isAttachmentOwnerPinned
            ? Set(selectedSessions.filter {
                self.matchesCurrentSessionKey(incoming: $0.key, current: self.sessionKey)
            }.map(\.key))
            : []
        let routeLease = await self.transport.acquireSessionMutationRouteLease()
        guard let routeLease else {
            return ChatSessionBatchResult(
                succeededKeys: [],
                errorsByKey: Dictionary(uniqueKeysWithValues: orderedKeys.map {
                    ($0, String(localized: "Gateway changed before the thread operation started."))
                }))
        }
        let result = await ChatSessionBatchMutationRunner.run(keys: orderedKeys) { key in
            if let entry = entries[key] {
                switch action {
                case .archive where !ChatSessionSidebarModel.canArchiveSession(
                    entry,
                    mainSessionKey: mainSessionKey):
                    throw ChatSessionBatchValidationError.cannotArchive
                case .delete where !ChatSessionSidebarModel.canDeleteSession(
                    key: key,
                    mainSessionKey: mainSessionKey):
                    throw ChatSessionBatchValidationError.cannotDelete
                case .archive where attachmentBlockedKeys.contains(key),
                     .delete where attachmentBlockedKeys.contains(key):
                    throw ChatSessionBatchValidationError.attachmentOwnerPinned
                default:
                    break
                }
            }
            switch action {
            case .pin:
                try await routeLease.patchSession(
                    key: key,
                    label: nil,
                    category: nil,
                    pinned: true,
                    archived: nil,
                    unread: nil)
            case .unpin:
                try await routeLease.patchSession(
                    key: key,
                    label: nil,
                    category: nil,
                    pinned: false,
                    archived: nil,
                    unread: nil)
            case .archive:
                try await routeLease.patchSession(
                    key: key,
                    label: nil,
                    category: nil,
                    pinned: nil,
                    archived: true,
                    unread: nil)
            case .delete:
                try await routeLease.deleteSession(key: key)
            }
        }
        let succeeded = Set(result.succeededKeys)
        switch action {
        case .pin, .unpin:
            let pinned = action == .pin
            for index in self.sessions.indices where succeeded.contains(self.sessions[index].key) {
                self.sessions[index].pinned = pinned
                self.sessions[index].pinnedAt = pinned ? Date().timeIntervalSince1970 * 1000 : nil
            }
            self.sessions = OpenClawChatSessionListOrganizer.organize(self.sessions)
        case .archive, .delete:
            self.sessions.removeAll { succeeded.contains($0.key) }
            if succeeded.contains(where: {
                self.matchesCurrentSessionKey(incoming: $0, current: self.sessionKey)
            }) {
                self.switchSession(to: self.resolvedMainSessionKey)
            }
        }
        self.refreshSessions(limit: Self.sessionListFetchLimit)
        return result
    }

    public func requestSessionReset() {
        Task { await self.performReset() }
    }

    public func requestSessionCompact() {
        Task { await self.performCompact() }
    }

    public func fetchSessionList(search: String?, archived: Bool) async -> [OpenClawChatSessionEntry] {
        let normalizedSearch = search?.trimmingCharacters(in: .whitespacesAndNewlines)
        let query = normalizedSearch?.isEmpty == false ? normalizedSearch : nil
        do {
            let res = try await self.transport.listSessions(
                limit: Self.sessionListFetchLimit,
                search: query,
                archived: archived)
            return OpenClawChatSessionListOrganizer.organize(res.sessions)
        } catch {
            // A superseded (cancelled) fetch must not produce fallback rows;
            // the newer task owns the scoped list. Callers also guard on
            // Task.isCancelled before applying results.
            guard !(error is CancellationError), !Task.isCancelled else { return [] }
            guard !archived else { return [] }
            guard let query else { return self.sessions }
            return OpenClawChatSessionListOrganizer.filter(self.sessions, search: query)
        }
    }

    public func renameSession(key: String, label: String) {
        let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines)
        let nextLabel: String? = trimmed.isEmpty ? nil : trimmed
        let previous = self.sessions
        if let index = self.sessions.firstIndex(where: { $0.key == key }) {
            self.sessions[index].label = nextLabel
            self.sessions[index].displayName = nextLabel
        }
        Task {
            do {
                try await self.transport.patchSession(
                    key: key,
                    label: .some(nextLabel),
                    category: nil,
                    pinned: nil,
                    archived: nil,
                    unread: nil)
                self.refreshSessions()
            } catch {
                self.sessions = self.applyingLocalUnreadOverrides(to: previous)
                self.errorText = error.localizedDescription
                chatSessionActionsLogger.error(
                    "sessions.patch(label) failed \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    public func forkSession(key: String) async {
        guard self.canCreateSessionForImmediateSwitch() else { return }
        let initiatingSession = self.currentSessionSnapshot()
        do {
            let createdKey = try await self.transport.forkSession(parentKey: key)
                .trimmingCharacters(in: .whitespacesAndNewlines)
            guard !createdKey.isEmpty else { return }
            guard self.isCurrentSession(initiatingSession), self.canCreateSessionForImmediateSwitch() else {
                self.refreshSessions(limit: Self.sessionListFetchLimit)
                return
            }
            self.switchSession(to: createdKey)
        } catch {
            self.errorText = error.localizedDescription
            chatSessionActionsLogger.error(
                "sessions.create(fork) failed \(error.localizedDescription, privacy: .public)")
        }
    }

    public func rewindToMessage(_ message: OpenClawChatMessage) async {
        guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
        guard self.canPerformMessageSessionAction else { return }
        let initiatingSession = self.currentSessionSnapshot()
        guard await self.beginOutboxSessionMutation(initiatingSession) else { return }
        guard self.isCurrentSession(initiatingSession) else {
            await self.cancelOutboxSessionMutation(initiatingSession)
            return
        }
        do {
            let result = try await self.transport.rewindSession(
                sessionKey: initiatingSession.key,
                entryId: entryID)
            guard self.isCurrentSession(initiatingSession) else {
                await self.recoverOutboxAfterSessionMutationRefreshFailure(
                    initiatingSession,
                    branchingUnsupported: false)
                return
            }
            self.replyTarget = nil
            self.runMessageScopesByRunID.removeAll()
            self.provisionalFinalMessagesByID.removeAll()
            self.input = result.editorText ?? ""
            let historyRequest = self.beginHistoryRequest(for: initiatingSession)
            _ = await self.refreshHistoryAfterRun(historyRequest: historyRequest)
            guard self.isCurrentSession(initiatingSession) else {
                await self.recoverOutboxAfterSessionMutationRefreshFailure(
                    initiatingSession,
                    branchingUnsupported: false)
                return
            }
            await self.refreshSessionBranches(confirmingBranchChange: true)
        } catch {
            await self.cancelOutboxSessionMutation(initiatingSession)
            self.errorText = error.localizedDescription
            chatSessionActionsLogger.error(
                "sessions.rewind failed \(error.localizedDescription, privacy: .public)")
        }
    }

    @discardableResult
    public func refreshSessionBranches(confirmingBranchChange: Bool = false) async -> Bool {
        let session = self.currentSessionSnapshot()
        let refreshGeneration = self.beginSessionBranchesRefresh()
        let previousState = await self.captureOutboxBranchState(for: session)
        return await self.performSessionBranchesRefresh(
            for: session,
            refreshGeneration: refreshGeneration,
            previousState: previousState,
            purpose: confirmingBranchChange ? .finalizeMutation : .readOnly)
    }

    func refreshSessionBranches(
        for session: SessionSnapshot,
        preBootstrapBranchState: OpenClawChatOutboxBranchState?) async -> Bool
    {
        let refreshGeneration = self.beginSessionBranchesRefresh()
        return await self.performSessionBranchesRefresh(
            for: session,
            refreshGeneration: refreshGeneration,
            previousState: preBootstrapBranchState,
            purpose: .reconcile)
    }

    private func beginSessionBranchesRefresh() -> UInt64 {
        self.sessionBranchesRefreshGeneration &+= 1
        self.isLoadingSessionBranches = true
        return self.sessionBranchesRefreshGeneration
    }

    private func performSessionBranchesRefresh(
        for session: SessionSnapshot,
        refreshGeneration: UInt64,
        previousState: OpenClawChatOutboxBranchState?,
        purpose: SessionBranchesRefreshPurpose) async -> Bool
    {
        let connectionGeneration = self.outboxBranchConnectionGeneration
        defer {
            if self.isCurrentSession(session),
               refreshGeneration == self.sessionBranchesRefreshGeneration
            {
                self.isLoadingSessionBranches = false
            }
        }
        do {
            let response = try await self.transport.listSessionBranches(
                sessionKey: session.key,
                agentID: self.outboxAgentID(for: session))
            guard self.isCurrentSession(session),
                  refreshGeneration == self.sessionBranchesRefreshGeneration,
                  connectionGeneration == self.outboxBranchConnectionGeneration
            else {
                if case .finalizeMutation = purpose {
                    await self.recoverOutboxAfterSessionMutationRefreshFailure(
                        session,
                        branchingUnsupported: false)
                }
                return false
            }
            switch purpose {
            case .readOnly:
                if let outbox = self.outbox,
                   let scope = self.outboxBranchScope(for: session),
                   let expectedEpoch = previousState?.epoch,
                   let activeLeafEntryID = Self.activeBranchLeafEntryID(in: response.branches)
                {
                    _ = await outbox.updateLastActiveLeafEntryID(
                        activeLeafEntryID,
                        expectedEpoch: expectedEpoch,
                        for: scope)
                }
            case .reconcile:
                guard await self.reconcileOutboxBranchScope(
                    session,
                    branches: response.branches,
                    previousState: previousState,
                    connectionGeneration: connectionGeneration)
                else {
                    self.pauseOutboxBranchScope(session)
                    return false
                }
            case .finalizeMutation:
                guard let activeLeafEntryID = Self.activeBranchLeafEntryID(in: response.branches),
                      await self.confirmOutboxBranchChange(
                          session,
                          activeLeafEntryID: activeLeafEntryID)
                else {
                    await self.recoverOutboxAfterSessionMutationRefreshFailure(
                        session,
                        branchingUnsupported: false)
                    return false
                }
            }
            guard self.isCurrentSession(session),
                  refreshGeneration == self.sessionBranchesRefreshGeneration
            else { return false }
            self.sessionBranches = response.branches
            self.flushOutboxIfNeeded()
            return true
        } catch {
            guard self.isCurrentSession(session),
                  refreshGeneration == self.sessionBranchesRefreshGeneration,
                  connectionGeneration == self.outboxBranchConnectionGeneration
            else {
                if case .finalizeMutation = purpose {
                    await self.recoverOutboxAfterSessionMutationRefreshFailure(
                        session,
                        branchingUnsupported: false)
                }
                return false
            }
            chatSessionActionsLogger.debug(
                "sessions.branches.list failed \(error.localizedDescription, privacy: .public)")
            let branchingUnsupported = Self.branchListingIsUnsupported(error)
            switch purpose {
            case .readOnly:
                break
            case .reconcile where branchingUnsupported:
                self.allowOutboxReplayWithoutBranching(session)
            case .reconcile:
                self.pauseOutboxBranchScope(session)
            case .finalizeMutation:
                await self.recoverOutboxAfterSessionMutationRefreshFailure(
                    session,
                    branchingUnsupported: branchingUnsupported)
            }
            return false
        }
    }

    func refreshSessionBranchesForMenuPresentation() async {
        await self.refreshSessionBranches()
    }

    var canSwitchSessionBranch: Bool {
        !self.hasBlockingRunActivity &&
            !self.isSending &&
            !self.isAborting &&
            !self.hasUnresolvedOutboxCommandsForCurrentSession
    }

    var canPerformMessageSessionAction: Bool {
        !self.hasBlockingRunActivity &&
            !self.isSending &&
            !self.isAborting &&
            !self.hasPendingOutboxCommandsForCurrentSession
    }

    public func switchToBranch(_ leafEntryId: String) async {
        let normalizedLeafEntryID = leafEntryId.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalizedLeafEntryID.isEmpty else { return }
        guard self.canSwitchSessionBranch else { return }
        guard !self.sessionBranches.contains(where: {
            $0.leafEntryId == normalizedLeafEntryID && $0.active
        }) else { return }
        let initiatingSession = self.currentSessionSnapshot()
        let switchActivity = self.beginSessionBranchSwitchActivity(for: initiatingSession)
        defer { self.endSessionBranchSwitchActivity(switchActivity) }
        guard await self.beginOutboxSessionMutation(initiatingSession) else {
            return
        }
        guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else {
            await self.cancelOutboxSessionMutation(initiatingSession)
            return
        }
        do {
            try await self.transport.switchSessionBranch(
                sessionKey: initiatingSession.key,
                agentID: self.outboxAgentID(for: initiatingSession),
                leafEntryId: normalizedLeafEntryID)
            guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else {
                if await self.confirmOutboxBranchChange(
                    initiatingSession,
                    activeLeafEntryID: normalizedLeafEntryID) == false
                {
                    await self.recoverOutboxAfterSessionMutationRefreshFailure(
                        initiatingSession,
                        branchingUnsupported: false)
                }
                return
            }
            self.replyTarget = nil
            self.runMessageScopesByRunID.removeAll()
            self.provisionalFinalMessagesByID.removeAll()
            await self.reconcileSessionBranchChange(
                switchActivity,
                confirmedLeafEntryID: normalizedLeafEntryID)
        } catch {
            await self.cancelOutboxSessionMutation(initiatingSession)
            guard self.isCurrentSessionBranchSwitchActivity(switchActivity) else { return }
            self.errorText = error.localizedDescription
            chatSessionActionsLogger.error(
                "sessions.branches.switch failed \(error.localizedDescription, privacy: .public)")
        }
    }

    /// Dispatch parity with forkSession(key:): per-request server-lease guards in the
    /// transport plus post-RPC staleness re-checks, not the patch-flow route lease,
    /// which does not expose fork/rewind and would widen the lease API for no sibling.
    public func forkAtMessage(_ message: OpenClawChatMessage) async {
        guard let entryID = Self.sessionMutationEntryID(for: message) else { return }
        guard self.canPerformMessageSessionAction else { return }
        guard self.canCreateSessionForImmediateSwitch() else { return }
        let initiatingSession = self.currentSessionSnapshot()
        guard await self.beginOutboxSessionMutation(initiatingSession) else { return }
        guard self.isCurrentSession(initiatingSession), self.canCreateSessionForImmediateSwitch() else {
            await self.cancelOutboxSessionMutation(initiatingSession)
            return
        }
        do {
            let result = try await self.transport.forkSessionAtMessage(
                sessionKey: initiatingSession.key,
                entryId: entryID)
            // Fork leaves the source transcript unchanged, so its lease is only an entry gate.
            // Rewind repoints the source scope and confirms an epoch change instead.
            await self.cancelOutboxSessionMutation(initiatingSession)
            let createdKey = result.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
            guard !createdKey.isEmpty else { return }
            guard self.isCurrentSession(initiatingSession),
                  !self.hasBlockingRunActivity,
                  !self.isSending,
                  !self.isAborting,
                  self.canCreateSessionForImmediateSwitch()
            else {
                self.refreshSessions(limit: Self.sessionListFetchLimit)
                return
            }
            self.switchSession(to: createdKey)
            guard self.sessionKey == createdKey else { return }
            self.input = result.editorText ?? ""
        } catch {
            await self.cancelOutboxSessionMutation(initiatingSession)
            self.errorText = error.localizedDescription
            chatSessionActionsLogger.error(
                "sessions.fork failed \(error.localizedDescription, privacy: .public)")
        }
    }

    private static func sessionMutationEntryID(for message: OpenClawChatMessage) -> String? {
        guard message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "user"
        else { return nil }
        let entryID = message.transcriptMessageID?.trimmingCharacters(in: .whitespacesAndNewlines)
        return entryID?.isEmpty == false ? entryID : nil
    }

    public func setSessionUnread(key: String, unread: Bool) {
        let identityKey = self.sessionMutationIdentity(for: key)
        let previousEntry = self.sessions.first(where: { $0.key == key })
        let rollbackUnread = self.unreadPatchGuard.confirmedUnread(key: identityKey) ?? previousEntry?.unread
        let revision = self.unreadPatchGuard.beginExplicitPatch(
            key: identityKey,
            unread: unread,
            isActive: self.matchesCurrentSessionKey(incoming: key, current: self.sessionKey))
        if let index = self.sessions.firstIndex(where: { $0.key == key }) {
            self.sessions[index].unread = unread
        }
        let routeLease = Task { await self.transport.acquireSessionMutationRouteLease() }
        let operation = self.unreadMutationQueue.reserve(
            routeLease: routeLease,
            queueKey: identityKey,
            routeKey: key,
            unread: unread)
        Task {
            do {
                try await operation.value
                guard self.unreadPatchGuard.patchSucceeded(
                    key: identityKey,
                    unread: unread,
                    revision: revision)
                else { return }
                self.refreshSessions()
            } catch {
                guard self.unreadPatchGuard.patchFailed(key: identityKey, revision: revision) else { return }
                if let index = self.sessions.firstIndex(where: { $0.key == key }),
                   self.sessions[index].unread == unread
                {
                    self.sessions[index].unread = rollbackUnread
                }
                self.refreshSessions()
                self.errorText = error.localizedDescription
                chatSessionActionsLogger.error(
                    "sessions.patch(unread) failed \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    public func setSessionPinned(key: String, pinned: Bool) {
        let previous = self.sessions
        if let index = self.sessions.firstIndex(where: { $0.key == key }) {
            self.sessions[index].pinned = pinned
            self.sessions[index].pinnedAt = pinned ? Date().timeIntervalSince1970 * 1000 : nil
            self.sessions = OpenClawChatSessionListOrganizer.organize(self.sessions)
        }
        Task {
            do {
                try await self.transport.patchSession(
                    key: key,
                    label: nil,
                    category: nil,
                    pinned: pinned,
                    archived: nil,
                    unread: nil)
                self.refreshSessions()
            } catch {
                self.sessions = self.applyingLocalUnreadOverrides(to: previous)
                self.errorText = error.localizedDescription
                chatSessionActionsLogger.error(
                    "sessions.patch(pinned) failed \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    public func setSessionArchived(key: String, archived: Bool) {
        guard archived else {
            Task { await self.restoreSession(key: key) }
            return
        }
        let previous = self.sessions
        self.sessions.removeAll { $0.key == key }
        Task {
            do {
                try await self.transport.patchSession(
                    key: key,
                    label: nil,
                    category: nil,
                    pinned: nil,
                    archived: true,
                    unread: nil)
                if self.matchesCurrentSessionKey(incoming: key, current: self.sessionKey) {
                    // The archived session rejects new sends; move the user back
                    // to the main session instead of leaving a dead composer.
                    self.switchSession(to: self.resolvedMainSessionKey)
                }
                self.refreshSessions()
            } catch {
                self.sessions = self.applyingLocalUnreadOverrides(to: previous)
                self.errorText = error.localizedDescription
                chatSessionActionsLogger.error(
                    "sessions.patch(archived) failed \(error.localizedDescription, privacy: .public)")
            }
        }
    }

    /// Restores an archived session. Returns false (with `errorText` set) on
    /// failure so open-flows can avoid switching into a still-archived session.
    @discardableResult
    public func restoreSession(key: String) async -> Bool {
        do {
            try await self.transport.patchSession(
                key: key,
                label: nil,
                category: nil,
                pinned: nil,
                archived: false,
                unread: nil)
            self.refreshSessions()
            return true
        } catch {
            self.errorText = error.localizedDescription
            chatSessionActionsLogger.error(
                "sessions.patch(archived=false) failed \(error.localizedDescription, privacy: .public)")
            return false
        }
    }

    func markCurrentSessionReadAfterActivation(
        _ session: SessionSnapshot,
        fallbackEntry: OpenClawChatSessionEntry?) async
    {
        guard self.isCurrentSession(session), self.hasAppliedLiveHistory,
              let entry = self.currentSessionEntry() ?? fallbackEntry,
              let revision = self.unreadPatchGuard.shouldPatch(
                  key: self.sessionMutationIdentity(for: entry.key, listedKey: entry.key),
                  unread: entry.unread)
        else { return }
        let identityKey = self.sessionMutationIdentity(for: entry.key, listedKey: entry.key)
        let routeLease = Task { await self.transport.acquireSessionMutationRouteLease() }
        let operation = self.unreadMutationQueue.reserve(
            routeLease: routeLease,
            queueKey: identityKey,
            routeKey: entry.key,
            unread: false)
        do {
            try await operation.value
            guard self.unreadPatchGuard.patchSucceeded(
                key: identityKey,
                unread: false,
                revision: revision)
            else { return }
            if let index = self.sessions.firstIndex(where: { $0.key == entry.key }) {
                self.sessions[index].unread = false
            }
        } catch {
            guard self.unreadPatchGuard.patchFailed(key: identityKey, revision: revision) else { return }
            chatSessionActionsLogger.error(
                "sessions.patch(unread=false) failed \(error.localizedDescription, privacy: .public)")
        }
    }
}
