import Foundation
import OpenClawKit
import SwiftUI

private enum ChatUIConstants {
    static let bubbleMaxWidth: CGFloat = 560
    static let bubbleCorner: CGFloat = 18
}

struct ChatAgentAvatar: View {
    let text: String?
    let name: String?
    let tint: Color?
    var size: CGFloat = 30

    var body: some View {
        Text(self.displayText)
            .font(OpenClawChatTypography.avatar(size: self.fontSize))
            .foregroundStyle(.white)
            .minimumScaleFactor(0.6)
            .lineLimit(1)
            .frame(width: self.size, height: self.size)
            .background(
                Circle()
                    .fill(
                        LinearGradient(
                            colors: [
                                (self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
                                Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
                            ],
                            startPoint: .topLeading,
                            endPoint: .bottomTrailing)))
            .overlay(
                Circle()
                    .strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
            .shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
            .accessibilityLabel(self.name.map {
                String(
                    format: String(localized: "%@ avatar"),
                    $0)
            } ?? String(localized: "Agent avatar"))
    }

    private var displayText: String {
        if let text = self.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
            return String(text.prefix(3))
        }
        if let name = self.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {
            let words = name.split(whereSeparator: { $0.isWhitespace || $0 == "-" || $0 == "_" }).prefix(2)
            let initials = words.compactMap(\.first).map(String.init).joined()
            if !initials.isEmpty {
                return initials.uppercased()
            }
        }
        return "OC"
    }

    private var fontSize: CGFloat {
        self.displayText.count > 2 ? self.size * 0.34 : self.size * 0.42
    }
}

private struct ChatBubbleShape: InsettableShape {
    enum Tail {
        case left
        case right
        case none
    }

    let cornerRadius: CGFloat
    let tail: Tail
    var insetAmount: CGFloat = 0

    private let tailWidth: CGFloat = 7
    private let tailBaseHeight: CGFloat = 9

    func inset(by amount: CGFloat) -> ChatBubbleShape {
        var copy = self
        copy.insetAmount += amount
        return copy
    }

    func path(in rect: CGRect) -> Path {
        let rect = rect.insetBy(dx: self.insetAmount, dy: self.insetAmount)
        switch self.tail {
        case .left:
            return self.leftTailPath(in: rect, radius: self.cornerRadius)
        case .right:
            return self.rightTailPath(in: rect, radius: self.cornerRadius)
        case .none:
            return Path(roundedRect: rect, cornerRadius: self.cornerRadius)
        }
    }

    private func rightTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
        var path = Path()
        let bubbleMinX = rect.minX
        let bubbleMaxX = rect.maxX - self.tailWidth
        let bubbleMinY = rect.minY
        let bubbleMaxY = rect.maxY

        let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
        let baseH = min(tailBaseHeight, available)
        let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
        let baseTopY = baseBottomY - baseH
        let midY = (baseTopY + baseBottomY) / 2

        let baseTop = CGPoint(x: bubbleMaxX, y: baseTopY)
        let baseBottom = CGPoint(x: bubbleMaxX, y: baseBottomY)
        let tip = CGPoint(x: bubbleMaxX + self.tailWidth, y: midY)

        path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
        path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
        path.addQuadCurve(
            to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
            control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
        path.addLine(to: baseTop)
        path.addCurve(
            to: tip,
            control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseTopY + baseH * 0.05),
            control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY - baseH * 0.15))
        path.addCurve(
            to: baseBottom,
            control1: CGPoint(x: bubbleMaxX + self.tailWidth * 0.95, y: midY + baseH * 0.15),
            control2: CGPoint(x: bubbleMaxX + self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05))
        self.addBottomEdge(
            path: &path,
            bubbleMinX: bubbleMinX,
            bubbleMaxX: bubbleMaxX,
            bubbleMaxY: bubbleMaxY,
            radius: r)
        path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
        path.addQuadCurve(
            to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
            control: CGPoint(x: bubbleMinX, y: bubbleMinY))

        return path
    }

    private func leftTailPath(in rect: CGRect, radius r: CGFloat) -> Path {
        var path = Path()
        let bubbleMinX = rect.minX + self.tailWidth
        let bubbleMaxX = rect.maxX
        let bubbleMinY = rect.minY
        let bubbleMaxY = rect.maxY

        let available = max(4, bubbleMaxY - bubbleMinY - 2 * r)
        let baseH = min(tailBaseHeight, available)
        let baseBottomY = bubbleMaxY - max(r * 0.45, 6)
        let baseTopY = baseBottomY - baseH
        let midY = (baseTopY + baseBottomY) / 2

        let baseTop = CGPoint(x: bubbleMinX, y: baseTopY)
        let baseBottom = CGPoint(x: bubbleMinX, y: baseBottomY)
        let tip = CGPoint(x: bubbleMinX - self.tailWidth, y: midY)

        path.move(to: CGPoint(x: bubbleMinX + r, y: bubbleMinY))
        path.addLine(to: CGPoint(x: bubbleMaxX - r, y: bubbleMinY))
        path.addQuadCurve(
            to: CGPoint(x: bubbleMaxX, y: bubbleMinY + r),
            control: CGPoint(x: bubbleMaxX, y: bubbleMinY))
        path.addLine(to: CGPoint(x: bubbleMaxX, y: bubbleMaxY - r))
        self.addBottomEdge(
            path: &path,
            bubbleMinX: bubbleMinX,
            bubbleMaxX: bubbleMaxX,
            bubbleMaxY: bubbleMaxY,
            radius: r)
        path.addLine(to: baseBottom)
        path.addCurve(
            to: tip,
            control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseBottomY - baseH * 0.05),
            control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY + baseH * 0.15))
        path.addCurve(
            to: baseTop,
            control1: CGPoint(x: bubbleMinX - self.tailWidth * 0.95, y: midY - baseH * 0.15),
            control2: CGPoint(x: bubbleMinX - self.tailWidth * 0.2, y: baseTopY + baseH * 0.05))
        path.addLine(to: CGPoint(x: bubbleMinX, y: bubbleMinY + r))
        path.addQuadCurve(
            to: CGPoint(x: bubbleMinX + r, y: bubbleMinY),
            control: CGPoint(x: bubbleMinX, y: bubbleMinY))

        return path
    }

    private func addBottomEdge(
        path: inout Path,
        bubbleMinX: CGFloat,
        bubbleMaxX: CGFloat,
        bubbleMaxY: CGFloat,
        radius: CGFloat)
    {
        path.addQuadCurve(
            to: CGPoint(x: bubbleMaxX - radius, y: bubbleMaxY),
            control: CGPoint(x: bubbleMaxX, y: bubbleMaxY))
        path.addLine(to: CGPoint(x: bubbleMinX + radius, y: bubbleMaxY))
        path.addQuadCurve(
            to: CGPoint(x: bubbleMinX, y: bubbleMaxY - radius),
            control: CGPoint(x: bubbleMinX, y: bubbleMaxY))
    }
}

@MainActor
struct ChatMessageBubble: View {
    let message: OpenClawChatMessage
    let style: OpenClawChatView.Style
    let markdownVariant: ChatMarkdownVariant
    let userAccent: Color?
    let displayOptions: OpenClawChatDisplayOptions
    let assistantName: String?
    let assistantAvatarText: String?
    let assistantAvatarTint: Color?
    let showsAssistantAvatar: Bool
    let isClean: Bool
    let contextWindowTokens: Int?
    let userMessageExpanded: Bool
    let onToggleUserMessageExpanded: @MainActor () -> Void
    let inlineWidgetResolverReady: Bool
    let inlineWidgetResourceResolver: @MainActor @Sendable (
        String,
        OpenClawChatWidgetResource?) async -> OpenClawChatWidgetResource?

    var body: some View {
        if self.isUser {
            self.messageBody
                .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .trailing)
                .frame(maxWidth: .infinity, alignment: .trailing)
                .padding(.horizontal, 2)
        } else {
            HStack(alignment: .top, spacing: 8) {
                if self.showsAssistantAvatar {
                    ChatAgentAvatar(
                        text: self.assistantAvatarText,
                        name: self.assistantName,
                        tint: self.assistantAvatarTint)
                        .padding(.top, 1)
                }

                self.messageBody
                    .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .padding(.horizontal, 2)
        }
    }

    private var isUser: Bool {
        self.message.role.lowercased() == "user"
    }

    private var messageBody: some View {
        ChatMessageBody(
            message: self.message,
            isUser: self.isUser,
            style: self.style,
            markdownVariant: self.markdownVariant,
            userAccent: self.userAccent,
            displayOptions: self.displayOptions,
            isClean: self.isClean,
            contextWindowTokens: self.contextWindowTokens,
            userMessageExpanded: self.userMessageExpanded,
            onToggleUserMessageExpanded: self.onToggleUserMessageExpanded,
            inlineWidgetResolverReady: self.inlineWidgetResolverReady,
            inlineWidgetResourceResolver: self.inlineWidgetResourceResolver)
    }
}

enum ChatUserMessageDisclosurePolicy {
    static let collapsedLineLimit = 12
    static let collapsedCharacterLimit = 700

    static func collapsedPreview(_ text: String) -> String? {
        var end = Self.stringIndex(atUTF16Offset: min(text.utf16.count, Self.collapsedCharacterLimit), in: text)
        var lineCount = 1
        for index in text.indices where index < end {
            guard text[index] == "\n" else { continue }
            if lineCount == Self.collapsedLineLimit {
                end = index
                break
            }
            lineCount += 1
        }
        guard end < text.endIndex else { return nil }
        return String(text[..<end]).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
    }

    private static func stringIndex(atUTF16Offset offset: Int, in text: String) -> String.Index {
        var safeOffset = offset
        while safeOffset > 0 {
            let utf16Index = text.utf16.index(text.utf16.startIndex, offsetBy: safeOffset)
            if let index = String.Index(utf16Index, within: text) {
                return index
            }
            safeOffset -= 1
        }
        return text.startIndex
    }
}

@MainActor
private struct ChatMessageBody: View {
    @Environment(\.openClawAssistantBubblesInCleanChrome) private var assistantBubblesInClean
    let message: OpenClawChatMessage
    let isUser: Bool
    let style: OpenClawChatView.Style
    let markdownVariant: ChatMarkdownVariant
    let userAccent: Color?
    let displayOptions: OpenClawChatDisplayOptions
    let isClean: Bool
    let contextWindowTokens: Int?
    let userMessageExpanded: Bool
    let onToggleUserMessageExpanded: @MainActor () -> Void
    let inlineWidgetResolverReady: Bool
    let inlineWidgetResourceResolver: @MainActor @Sendable (
        String,
        OpenClawChatWidgetResource?) async -> OpenClawChatWidgetResource?

    var body: some View {
        let text = self.primaryText
        let textColor = self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText
        let shouldRenderBubble = self.shouldRenderBubble
        let toolActivityItems = self.toolActivityItems

        VStack(alignment: .leading, spacing: 6) {
            if shouldRenderBubble {
                if self.usesBubble {
                    self.messageContent(text: text, textColor: textColor)
                        .padding(.vertical, 10)
                        .padding(.horizontal, 12)
                        .background(self.bubbleBackground)
                        .clipShape(self.bubbleShape)
                        .overlay(self.bubbleBorder)
                        .shadow(
                            color: self.bubbleShadowColor,
                            radius: self.bubbleShadowRadius,
                            y: self.bubbleShadowYOffset)
                        .padding(.leading, self.tailPaddingLeading)
                        .padding(.trailing, self.tailPaddingTrailing)
                } else {
                    self.messageContent(text: text, textColor: textColor)
                        .padding(.vertical, 5)
                        .padding(.horizontal, 4)
                }
            }

            if !toolActivityItems.isEmpty {
                ChatToolActivityList(items: toolActivityItems)
                    .padding(.horizontal, 4)
            }

            if !shouldRenderBubble, let usagePresentation = self.usagePresentation {
                self.usageLine(usagePresentation)
                    .padding(.horizontal, 4)
            }
        }
    }

    private func messageContent(text: String, textColor: Color) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            if self.isUser {
                self.userMessageText(text: text, textColor: textColor)
            } else {
                ChatAssistantTextBody(
                    text: text,
                    markdownVariant: self.markdownVariant,
                    includesThinking: self.displayOptions.contains(.reasoning))
            }

            if self.showsLinkPreview, let previewURL = chatFirstPreviewURL(in: text) {
                ChatLinkPreview(url: previewURL)
            }

            if !self.inlineAttachments.isEmpty {
                ForEach(self.inlineAttachments.indices, id: \.self) { idx in
                    AttachmentRow(att: self.inlineAttachments[idx], isUser: self.isUser)
                }
            }

            ForEach(self.inlineWidgets.indices, id: \.self) { idx in
                ChatInlineWidgetView(
                    preview: self.inlineWidgets[idx],
                    resolverReady: self.inlineWidgetResolverReady,
                    resolveResource: self.inlineWidgetResourceResolver)
            }

            if let usagePresentation = self.usagePresentation {
                self.usageLine(usagePresentation)
            }
        }
        .textSelection(.enabled)
        .foregroundStyle(textColor)
    }

    @ViewBuilder
    private func userMessageText(text: String, textColor: Color) -> some View {
        let preview = ChatUserMessageDisclosurePolicy.collapsedPreview(text)

        if let preview, !self.userMessageExpanded {
            Text(preview)
                .font(OpenClawChatTypography.body)
                .foregroundStyle(textColor)
                .fixedSize(horizontal: false, vertical: true)
        } else {
            self.userMarkdown(text: text, textColor: textColor)
        }

        if preview != nil {
            Button {
                withAnimation(.easeOut(duration: 0.16)) {
                    self.onToggleUserMessageExpanded()
                }
            } label: {
                Text(String(localized: self.userMessageExpanded ? "Show less" : "Show more"))
                    .font(OpenClawChatTypography.caption)
                    .foregroundStyle(textColor.opacity(0.78))
                    .padding(.horizontal, 10)
                    .frame(minHeight: 30)
                    .background(
                        RoundedRectangle(cornerRadius: 8, style: .continuous)
                            .fill(Color.white.opacity(0.14)))
                    .overlay(
                        RoundedRectangle(cornerRadius: 8, style: .continuous)
                            .stroke(Color.white.opacity(0.12), lineWidth: 0.5))
            }
            .buttonStyle(.plain)
            .accessibilityValue(String(
                localized: self.userMessageExpanded ? "Expanded" : "Collapsed"))
            .accessibilityIdentifier("chat-user-message-disclosure-toggle")
        }
    }

    private func userMarkdown(text: String, textColor: Color) -> some View {
        ChatMarkdownRenderer(
            text: text,
            context: .user,
            variant: self.markdownVariant,
            font: OpenClawChatTypography.body,
            textColor: textColor)
    }

    private func usageLine(_ presentation: ChatMessageUsagePresentation) -> some View {
        Text(presentation.text)
            .font(OpenClawChatTypography.caption2)
            .monospacedDigit()
            .foregroundStyle(self.usageTint(presentation.pressure))
            .fixedSize(horizontal: false, vertical: true)
            .accessibilityElement(children: .ignore)
            .accessibilityLabel(String(localized: "Message usage"))
            .accessibilityValue(presentation.accessibilityValue)
    }

    private var usesBubble: Bool {
        // Keep the guarded base condition; iOS additionally opts assistant
        // messages into bubbles via the clean-chrome environment flag.
        self.isUser || self.style == .onboarding || !self.isClean || self.assistantBubblesInClean
    }

    private var shouldRenderBubble: Bool {
        guard !self.isToolResultMessage else { return false }
        return !self.primaryText.isEmpty ||
            !self.inlineAttachments.isEmpty ||
            !self.inlineWidgets.isEmpty ||
            (self.showsLinkPreview && chatFirstPreviewURL(in: self.primaryText) != nil)
    }

    private var toolActivityItems: [ChatToolActivityItem] {
        guard self.displayOptions.contains(.toolActivity) else { return [] }
        // Results normally reach us merged into the calling assistant message
        // (OpenClawChatView.mergeToolResults); this branch is the orphan
        // fallback for results whose call is not in the preceding message.
        if self.isToolResultMessage {
            return [ChatToolActivityItem(
                id: self.message.content.first?.id ?? "result-0",
                name: self.message.toolName,
                arguments: nil,
                details: self.message.details,
                resultText: self.primaryText,
                isError: self.message.isError ?? false,
                isPending: false)]
        }
        guard self.message.role.lowercased() == "assistant" else { return [] }
        return ChatToolActivity.items(calls: self.toolCalls, results: self.inlineToolResults)
    }

    private var showsLinkPreview: Bool {
        let role = self.message.role.lowercased()
        return role == "user" || role == "assistant"
    }

    private var primaryText: String {
        let parts = self.message.content.compactMap { content -> String? in
            let kind = (content.type ?? "text").lowercased()
            guard kind == "text" || kind.isEmpty else { return nil }
            return content.text
        }
        return OpenClawChatMessage.displayText(
            contentText: parts.joined(separator: "\n"),
            role: self.message.role,
            stopReason: self.message.stopReason,
            errorMessage: self.message.errorMessage)
    }

    private var inlineAttachments: [OpenClawChatMessageContent] {
        self.message.content.filter { content in
            switch content.type ?? "text" {
            case "file", "attachment":
                true
            default:
                false
            }
        }
    }

    private var inlineWidgets: [OpenClawChatCanvasPreview] {
        guard self.message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "assistant"
        else { return [] }
        return self.message.content.compactMap { content in
            guard (content.type ?? "").lowercased() == "canvas",
                  content.preview?.inlineWidgetPath != nil
            else { return nil }
            return content.preview
        }
    }

    private var toolCalls: [OpenClawChatMessageContent] {
        self.message.content.filter { content in
            let kind = (content.type ?? "").lowercased()
            if ["toolcall", "tool_call", "tooluse", "tool_use"].contains(kind) {
                return true
            }
            return content.name != nil && content.arguments != nil
        }
    }

    private var inlineToolResults: [OpenClawChatMessageContent] {
        self.message.content.filter { content in
            let kind = (content.type ?? "").lowercased()
            return kind == "toolresult" || kind == "tool_result"
        }
    }

    private var isToolResultMessage: Bool {
        let role = self.message.role.lowercased()
        return role == "toolresult" || role == "tool_result"
    }

    private var usagePresentation: ChatMessageUsagePresentation? {
        ChatMessageUsagePresentation.make(
            message: self.message,
            contextWindowTokens: self.contextWindowTokens)
    }

    private func usageTint(_ pressure: ChatMessageUsagePresentation.Pressure) -> Color {
        switch pressure {
        case .normal:
            OpenClawChatTheme.muted
        case .warning:
            OpenClawChatTheme.warning
        case .danger:
            OpenClawChatTheme.danger
        }
    }

    private var bubbleFillColor: Color {
        if self.isUser {
            return self.userAccent ?? OpenClawChatTheme.userBubble
        }
        if self.style == .onboarding {
            return OpenClawChatTheme.onboardingAssistantBubble
        }
        return OpenClawChatTheme.assistantBubble
    }

    private var bubbleBackground: AnyShapeStyle {
        AnyShapeStyle(self.bubbleFillColor)
    }

    private var bubbleBorderColor: Color {
        if self.isUser {
            return Color.white.opacity(0.12)
        }
        if self.style == .onboarding {
            return OpenClawChatTheme.onboardingAssistantBorder
        }
        return Color.white.opacity(0.08)
    }

    private var bubbleBorderWidth: CGFloat {
        if self.isUser { return 0.5 }
        if self.style == .onboarding { return 0.8 }
        return 1
    }

    private var bubbleBorder: some View {
        self.bubbleShape.strokeBorder(self.bubbleBorderColor, lineWidth: self.bubbleBorderWidth)
    }

    private var bubbleShape: ChatBubbleShape {
        ChatBubbleShape(cornerRadius: ChatUIConstants.bubbleCorner, tail: self.bubbleTail)
    }

    private var bubbleTail: ChatBubbleShape.Tail {
        guard self.style == .onboarding else { return .none }
        return self.isUser ? .right : .left
    }

    private var tailPaddingLeading: CGFloat {
        self.style == .onboarding && !self.isUser ? 8 : 0
    }

    private var tailPaddingTrailing: CGFloat {
        self.style == .onboarding && self.isUser ? 8 : 0
    }

    private var bubbleShadowColor: Color {
        self.style == .onboarding && !self.isUser ? Color.black.opacity(0.28) : .clear
    }

    private var bubbleShadowRadius: CGFloat {
        self.style == .onboarding && !self.isUser ? 6 : 0
    }

    private var bubbleShadowYOffset: CGFloat {
        self.style == .onboarding && !self.isUser ? 2 : 0
    }
}

private struct AttachmentRow: View {
    let att: OpenClawChatMessageContent
    let isUser: Bool

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: self.isAudio ? "waveform" : "paperclip")
            Text(self.isAudio ? "Voice note" : (self.att.fileName ?? "Attachment"))
                .font(OpenClawChatTypography.footnote)
                .lineLimit(1)
                .foregroundStyle(self.isUser ? OpenClawChatTheme.userText : OpenClawChatTheme.assistantText)
            if self.isAudio, let durationSeconds = self.att.durationSeconds {
                Text(openClawVoiceNoteDurationLabel(durationSeconds))
                    .font(OpenClawChatTypography.footnote)
                    .foregroundStyle(
                        self.isUser
                            ? OpenClawChatTheme.userText.opacity(0.72)
                            : OpenClawChatTheme.assistantText.opacity(0.72))
            }
            Spacer()
        }
        .padding(10)
        .background(self.isUser ? Color.white.opacity(0.2) : Color.black.opacity(0.04))
        .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
    }

    private var isAudio: Bool {
        self.att.mimeType?.hasPrefix("audio/") == true
    }
}

@MainActor
struct ChatTypingIndicatorBubble: View {
    let style: OpenClawChatView.Style
    let assistantName: String?
    let assistantAvatarText: String?
    let assistantAvatarTint: Color?
    let showsAssistantAvatar: Bool
    let isClean: Bool
    let runIdentity: String

    var body: some View {
        HStack(alignment: .center, spacing: 8) {
            if self.showsAssistantAvatar {
                ChatAgentAvatar(
                    text: self.assistantAvatarText,
                    name: self.assistantName,
                    tint: self.assistantAvatarTint,
                    size: 28)
            }

            HStack(spacing: 9) {
                ChatWorkingIndicatorContent(runIdentity: self.runIdentity)
                    .id(self.runIdentity)
            }
            .padding(.vertical, self.isClean ? 5 : (self.style == .standard ? 10 : 9))
            .padding(.horizontal, self.isClean ? 4 : (self.style == .standard ? 12 : 14))
            .assistantBubbleContainerStyle(isClean: self.isClean, cornerRadius: 15)
            .fixedSize(horizontal: true, vertical: false)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .focusable(false)
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(
            Text("Writing")
                .font(OpenClawChatTypography.caption))
    }
}

private struct ChatWorkingIndicatorContent: View {
    @State private var startedAt: Date
    let seed: String

    init(runIdentity: String) {
        _startedAt = State(initialValue: Date())
        self.seed = runIdentity
    }

    var body: some View {
        HStack(spacing: 9) {
            ChatWorkingClawView(seed: self.seed)
            ChatWorkingStatusText(startedAt: self.startedAt, seed: self.seed)
        }
    }
}

/// Inline playback state under an assistant bubble while Listen is active;
/// tapping it stops speech.
struct ChatSpeechStatusChip: View {
    let isPreparing: Bool
    let onStop: () -> Void

    var body: some View {
        Button(action: self.onStop) {
            HStack(spacing: 4) {
                Image(systemName: self.isPreparing ? "hourglass" : "speaker.wave.2.fill")
                    .font(.system(size: 10, weight: .semibold))
                if self.isPreparing {
                    Text("Preparing audio…")
                        .font(OpenClawChatTypography.caption)
                } else {
                    Text("Speaking…")
                        .font(OpenClawChatTypography.caption)
                }
            }
            .foregroundStyle(.secondary)
        }
        .buttonStyle(.plain)
        .accessibilityLabel(self.isPreparing
            ? "Preparing audio, tap to cancel"
            : "Speaking, tap to stop")
    }
}

/// Status footer for a user bubble backed by the durable offline outbox.
@MainActor
struct ChatOutboxStatusLabel: View {
    let state: OpenClawChatOutboxMessageState

    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: self.iconName)
                .font(.system(size: 10, weight: .semibold))
            Text(self.title)
                .font(OpenClawChatTypography.caption)
        }
        .foregroundStyle(self.state.isFailed ? AnyShapeStyle(OpenClawChatTheme.danger) : AnyShapeStyle(.secondary))
        .accessibilityElement(children: .combine)
        .accessibilityLabel(
            Text(self.accessibilityText)
                .font(OpenClawChatTypography.caption))
    }

    private var title: LocalizedStringResource {
        switch self.state {
        case .queued:
            "Queued"
        case .sending:
            "Sending…"
        case .confirming:
            "Confirming…"
        case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
            "Delivery unknown"
        case .failed:
            "Not sent"
        }
    }

    private var iconName: String {
        switch self.state {
        case .queued:
            "clock"
        case .sending:
            "arrow.up.circle"
        case .confirming:
            "checkmark.circle"
        case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
            "questionmark.circle"
        case .failed:
            "exclamationmark.circle"
        }
    }

    private var accessibilityText: LocalizedStringResource {
        switch self.state {
        case .queued:
            "Queued, sends when reconnected"
        case .sending:
            "Sending"
        case .confirming:
            "Sent, waiting for chat history confirmation"
        case let .failed(reason) where reason == OpenClawChatSQLiteTranscriptCache.outboxUnconfirmedError:
            "Delivery unconfirmed, touch and hold to retry or delete"
        case .failed:
            "Not sent, touch and hold to retry or delete"
        }
    }
}

extension ChatTypingIndicatorBubble: @MainActor Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.style == rhs.style &&
            lhs.assistantName == rhs.assistantName &&
            lhs.assistantAvatarText == rhs.assistantAvatarText &&
            lhs.showsAssistantAvatar == rhs.showsAssistantAvatar &&
            lhs.isClean == rhs.isClean &&
            lhs.runIdentity == rhs.runIdentity
    }
}

// Keep this explicit for SwiftPM toolchains where SwiftUI macro plugins are unavailable.
// swiftformat:disable environmentEntry
private struct OpenClawAssistantBubblesInCleanChromeKey: EnvironmentKey {
    static let defaultValue = false
}

extension EnvironmentValues {
    /// Clients that want iMessage-style assistant bubbles in the clean chrome
    /// (the iOS app) opt in; the default keeps the plain clean look elsewhere.
    public var openClawAssistantBubblesInCleanChrome: Bool {
        get { self[OpenClawAssistantBubblesInCleanChromeKey.self] }
        set { self[OpenClawAssistantBubblesInCleanChromeKey.self] = newValue }
    }
}

// swiftformat:enable environmentEntry

private struct AssistantBubbleContainerStyle: ViewModifier {
    let isClean: Bool
    let cornerRadius: CGFloat

    @Environment(\.openClawAssistantBubblesInCleanChrome) private var bubblesInClean

    func body(content: Content) -> some View {
        if self.isClean, !self.bubblesInClean {
            content
        } else {
            content
                // Clean call sites pre-pad only ~4pt; bubbles need room to breathe.
                    .padding(self.isClean ? 8 : 0)
                    .background(
                        RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
                            .fill(OpenClawChatTheme.assistantBubble))
                    .overlay(
                        RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)
                            .strokeBorder(Color.white.opacity(0.08), lineWidth: 1))
        }
    }
}

extension View {
    fileprivate func assistantBubbleContainerStyle(isClean: Bool, cornerRadius: CGFloat = 16) -> some View {
        self.modifier(AssistantBubbleContainerStyle(isClean: isClean, cornerRadius: cornerRadius))
            .frame(maxWidth: ChatUIConstants.bubbleMaxWidth, alignment: .leading)
            .focusable(false)
    }
}

@MainActor
struct ChatStreamingAssistantBubble: View {
    let text: String
    let markdownVariant: ChatMarkdownVariant
    let showsReasoning: Bool
    let assistantName: String?
    let assistantAvatarText: String?
    let assistantAvatarTint: Color?
    let showsAssistantAvatar: Bool
    let isClean: Bool

    var body: some View {
        HStack(alignment: .top, spacing: 8) {
            if self.showsAssistantAvatar {
                ChatAgentAvatar(
                    text: self.assistantAvatarText,
                    name: self.assistantName,
                    tint: self.assistantAvatarTint)
                    .padding(.top, 1)
            }

            VStack(alignment: .leading, spacing: 10) {
                ChatAssistantTextBody(
                    text: self.text,
                    markdownVariant: self.markdownVariant,
                    includesThinking: self.showsReasoning,
                    isComplete: false)
            }
            .padding(self.isClean ? 4 : 12)
            .assistantBubbleContainerStyle(isClean: self.isClean)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

@MainActor
struct ChatPendingToolsBubble: View {
    let toolCalls: [OpenClawChatPendingToolCall]

    var body: some View {
        ChatToolActivityList(items: self.items)
            .padding(4)
    }

    private var items: [ChatToolActivityItem] {
        self.toolCalls.map { call in
            ChatToolActivityItem(
                id: call.id,
                name: call.name,
                arguments: call.args,
                details: nil,
                resultText: nil,
                isError: false,
                isPending: true)
        }
    }
}

extension ChatPendingToolsBubble: @MainActor Equatable {
    static func == (lhs: Self, rhs: Self) -> Bool {
        lhs.toolCalls == rhs.toolCalls
    }
}

private struct ChatAssistantTextBody: View {
    let text: String
    let markdownVariant: ChatMarkdownVariant
    let includesThinking: Bool
    var isComplete: Bool = true

    var body: some View {
        if self.isComplete {
            self.completeBody
        } else {
            ChatStreamingAssistantTextBody(
                text: self.text,
                markdownVariant: self.markdownVariant,
                includesThinking: self.includesThinking)
        }
    }

    private var completeBody: some View {
        let segments = AssistantTextParser.segments(from: self.text, includeThinking: self.includesThinking)
        return VStack(alignment: .leading, spacing: 10) {
            ForEach(segments) { segment in
                let font = segment.kind == .thinking
                    ? OpenClawChatTypography.callout.italic()
                    : OpenClawChatTypography.body
                let inlineMathTypography: ChatMarkdownRenderer.InlineMathTypography = segment.kind == .thinking
                    ? .callout
                    : .body
                ChatMarkdownRenderer(
                    text: segment.text,
                    context: .assistant,
                    variant: self.markdownVariant,
                    font: font,
                    textColor: OpenClawChatTheme.assistantText,
                    inlineMathTypography: inlineMathTypography,
                    isComplete: self.isComplete)
            }
        }
    }
}

@MainActor
private struct ChatStreamingAssistantTextBody: View {
    let text: String
    let markdownVariant: ChatMarkdownVariant
    let includesThinking: Bool

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var snapshot: Snapshot
    @State private var revealState: ChatStreamingRevealState
    @State private var revealLocation: Snapshot.ProseLocation?
    @State private var pendingUntil: TimeInterval?

    init(text: String, markdownVariant: ChatMarkdownVariant, includesThinking: Bool) {
        self.text = text
        self.markdownVariant = markdownVariant
        self.includesThinking = includesThinking

        let now = Date.timeIntervalSinceReferenceDate
        let snapshot = Snapshot(text: text, includesThinking: includesThinking)
        let location = snapshot.lastProseLocation
        let revealState = location.map {
            step(state: ChatStreamingRevealState(), newText: snapshot.prose(at: $0).plainText, now: now)
        } ?? ChatStreamingRevealState()
        self._snapshot = State(initialValue: snapshot)
        self._revealState = State(initialValue: revealState)
        self._revealLocation = State(initialValue: location)
        self._pendingUntil = State(initialValue: revealState.latestDeadline)
    }

    var body: some View {
        Group {
            if self.reduceMotion || self.pendingUntil == nil {
                self.render(now: nil)
            } else {
                TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in
                    self.render(now: timeline.date.timeIntervalSinceReferenceDate)
                }
            }
        }
        .onChange(of: self.text) { _, _ in
            self.updateSnapshot()
        }
        .onChange(of: self.includesThinking) { _, _ in
            self.updateSnapshot()
        }
        .onChange(of: self.reduceMotion) { _, reduceMotion in
            self.pendingUntil = reduceMotion ? nil : self.futureDeadline()
        }
        .onAppear {
            if self.snapshot.sourceText != self.text || self.snapshot.includesThinking != self.includesThinking {
                self.updateSnapshot()
            }
        }
        .task(id: self.pendingUntil) {
            guard let pendingUntil = self.pendingUntil else { return }
            let delay = max(0, pendingUntil - Date.timeIntervalSinceReferenceDate)
            if delay > 0 {
                try? await Task.sleep(for: .seconds(delay))
            }
            guard !Task.isCancelled, self.pendingUntil == pendingUntil else { return }
            self.pendingUntil = nil
        }
    }

    private func render(now: TimeInterval?) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            ForEach(Array(self.snapshot.segments.enumerated()), id: \.offset) { entry in
                let segment = entry.element
                let font = segment.kind == .thinking
                    ? OpenClawChatTypography.callout.italic()
                    : OpenClawChatTypography.body
                let inlineMathTypography: ChatMarkdownRenderer.InlineMathTypography = segment.kind == .thinking
                    ? .callout
                    : .body
                let reveal = self.reveal(
                    segmentIndex: entry.offset,
                    now: now)
                ChatMarkdownRenderer(
                    snapshot: segment.markdown,
                    context: .assistant,
                    variant: self.markdownVariant,
                    font: font,
                    textColor: OpenClawChatTheme.assistantText,
                    inlineMathTypography: inlineMathTypography,
                    reveal: reveal)
            }
        }
    }

    private func reveal(segmentIndex: Int, now: TimeInterval?) -> ChatMarkdownProseReveal? {
        guard let now,
              let location = self.revealLocation,
              location.segmentIndex == segmentIndex
        else { return nil }
        return ChatMarkdownProseReveal(
            blockIndex: location.blockIndex,
            state: self.revealState,
            now: now)
    }

    private func updateSnapshot() {
        let now = Date.timeIntervalSinceReferenceDate
        let nextSnapshot = Snapshot(text: self.text, includesThinking: self.includesThinking)
        let nextLocation = nextSnapshot.lastProseLocation
        let nextRevealState: ChatStreamingRevealState
        if let nextLocation {
            let nextText = nextSnapshot.prose(at: nextLocation).plainText
            if nextLocation == self.revealLocation {
                nextRevealState = step(state: self.revealState, newText: nextText, now: now)
            } else {
                nextRevealState = step(state: ChatStreamingRevealState(), newText: nextText, now: now)
            }
        } else {
            nextRevealState = ChatStreamingRevealState()
        }

        self.snapshot = nextSnapshot
        self.revealLocation = nextLocation
        self.revealState = nextRevealState
        self.pendingUntil = self.reduceMotion ? nil : self.futureDeadline(now: now, state: nextRevealState)
    }

    private func futureDeadline(
        now: TimeInterval = Date.timeIntervalSinceReferenceDate,
        state: ChatStreamingRevealState? = nil) -> TimeInterval?
    {
        guard let deadline = (state ?? self.revealState).latestDeadline, deadline > now else {
            return nil
        }
        return deadline
    }

    @MainActor
    private struct Snapshot {
        struct Segment {
            let kind: AssistantTextSegment.Kind
            let markdown: ChatMarkdownRenderSnapshot
        }

        struct ProseLocation: Equatable {
            let segmentIndex: Int
            let blockIndex: Int
        }

        let segments: [Segment]
        let lastProseLocation: ProseLocation?
        let sourceText: String
        let includesThinking: Bool

        init(text: String, includesThinking: Bool) {
            let segments = AssistantTextParser.segments(
                from: text,
                includeThinking: includesThinking).map {
                Segment(
                    kind: $0.kind,
                    markdown: ChatMarkdownRenderSnapshot(
                        text: $0.text,
                        isComplete: false,
                        preparesReveal: true))
            }
            self.segments = segments
            self.sourceText = text
            self.includesThinking = includesThinking
            self.lastProseLocation = segments.indices.reversed().compactMap { segmentIndex in
                segments[segmentIndex].markdown.lastProseIndex.map {
                    ProseLocation(segmentIndex: segmentIndex, blockIndex: $0)
                }
            }.first
        }

        func prose(at location: ProseLocation) -> ChatMarkdownProse {
            guard case let .prose(prose) = self.segments[location.segmentIndex]
                .markdown.blocks[location.blockIndex]
            else {
                preconditionFailure("Streaming reveal location must identify prose")
            }
            return prose
        }
    }
}
