import SwiftUI
import NucleicProtocol

/// One session: a full-height transcript with a status-driven action bar floating over it on
/// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on
/// appear, unsubscribes on disappear.
struct SessionDetailView: View {
    @EnvironmentObject var store: RemoteStore
    let sessionID: SessionID
    // Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews,
    // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
    @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
    @State private var draft = ""
    // Files/images staged for the next follow-up (see `ComposerAttachments`); shipped as bytes and
    // materialized into the session's working tree host-side. `attachmentsOverflowed` flags a pick
    // refused for exceeding the per-message size cap.
    @State private var attachments: [StagedAttachment] = []
    @State private var attachmentsOverflowed = false
    @State private var showRename = false
    @State private var renameDraft = ""
    @State private var showIntegrate = false
    @State private var showDiscard = false
    // Bumped on send to jump the transcript to the bottom, even if the user had scrolled up to
    // read history — sending is a deliberate "show me what happens next" (mirrors the Mac).
    @State private var scrollToBottomRequest = 0
    // Owned here (not inside `TranscriptList`) so the jump-to-bottom chevron can ride in the chat
    // bar just above the composer. `TranscriptList` drives it from the scroll geometry.
    @State private var isScrolledToBottom = true
    // The transcript's on-screen height, measured so the floating approval / question card can cap
    // itself to the screen: a long host command or a long list of options must never grow the card
    // past the screen edge and push its Allow/Deny (or Submit) buttons out of reach.
    @State private var availableHeight: CGFloat = 0
    // This mount's claim on the store's open-session state (`RemoteStore.openGeneration`). Two
    // details for the same session can be mounted at once (a Live Activity deep link onto the
    // already-open chat); the token lets `closeOpen` ignore the covered copy's teardown instead
    // of wiping the transcript the visible copy is showing.
    @State private var openToken = 0

    private var summary: WireSessionSummary? {
        store.sessions.first { $0.sessionID == sessionID }
    }

    /// Another device's live typing in this chat's composer (mesh composer streaming). While
    /// present, this composer is locked and renders the streamed draft; it unlocks when the
    /// typer sends or goes idle (their tombstone — or the local expiry guard — clears it).
    private var remoteTyping: ComposerTypingState? {
        store.composerTypingBySession[sessionID]
    }

    /// The friendly label for a remote typer, never blank.
    private func typerName(_ typing: ComposerTypingState) -> String {
        typing.deviceName.isEmpty ? "Another device" : typing.deviceName
    }

    var body: some View {
        // The detail's height comes from the enclosing GeometryReader — a value fixed by the parent
        // (the navigation content area), never by anything inside it. The attention cards (approval /
        // question) cap themselves to this so their buttons stay on-screen. Sourcing it from a
        // GeometryReader — rather than measuring the transcript, whose height the cards' own bottom
        // `safeAreaInset` resizes — makes the measurement categorically independent of the card, so
        // the card can't feed its height back into the value it's sized from. That feedback was an
        // unresolved layout cycle that pinned the main thread at 100% CPU during the push transition;
        // it showed up intermittently because a feedback loop only diverges for some content/card-
        // height combinations. It bit only the attention path (a card consumes this value; the plain
        // composer doesn't), which is why a "Working" tap opened fine and an "attention" tap hung.
        GeometryReader { proxy in
            TranscriptList(events: store.openEvents,
                           scrollToBottomRequest: scrollToBottomRequest,
                           isScrolledToBottom: $isScrolledToBottom)
                // The chat bar floats over the scrolling content on Liquid Glass instead of sitting
                // in a boxed strip below it, so the transcript runs the full height of the screen.
                .safeAreaInset(edge: .bottom) { actionArea }
                .navigationTitle(summary?.title ?? "Session")
                .navigationBarTitleDisplayMode(.inline)
                // With a session open the floating chat bar owns the bottom edge, so the compact
                // shell's `NucleicTabBar` slides away while this detail is pushed — driven by the
                // `open`/`closeOpen` below, which set `store.compactDetailPresented` (so the bar hides
                // from any entry point, not just the Sessions list), not a per-navigation tab-bar
                // toolbar hide (which restored the system bar late and made the "+" jump). A no-op in
                // the iPad split, which has no tab bar.
                .toolbar {
                    ToolbarItem(placement: .topBarTrailing) { sessionMenu }
                }
                .sheet(isPresented: $showDiff) { diffSheet }
                .alert("Rename chat", isPresented: $showRename) {
                    TextField("Title", text: $renameDraft)
                    Button("Cancel", role: .cancel) {}
                    Button("Rename") { store.renameSession(sessionID, to: renameDraft) }
                }
                .confirmationDialog("Integrate this branch", isPresented: $showIntegrate, titleVisibility: .visible) {
                    Button("Merge") { store.integrate(sessionID, .merge) }
                    Button("Squash & merge") { store.integrate(sessionID, .squash) }
                    Button("Rebase") { store.integrate(sessionID, .rebase) }
                    Button("Cancel", role: .cancel) {}
                }
                .confirmationDialog(
                    "Discard this session's branch and worktree? Unmerged work is lost.",
                    isPresented: $showDiscard, titleVisibility: .visible
                ) {
                    Button("Discard", role: .destructive) { store.discard(sessionID) }
                    Button("Cancel", role: .cancel) {}
                }
                .onAppear {
                    openToken = store.open(sessionID)
                    // Restore any unsent follow-up — the composer keeps what was typed across
                    // backgrounding, locking, or the view being torn down and remounted.
                    draft = store.sessionDraft(sessionID)
                }
                // Pass our own id + token so an iPad split-view A→B switch (which may mount B
                // before A disappears) unsubscribes A without tearing down B's just-opened state —
                // and so a covered duplicate of the *same* session tears down nothing at all.
                .onDisappear { store.closeOpen(sessionID, token: openToken) }
                .background { interruptShortcut }
                // Publish the parent-determined height to the cards. `initial: true` seeds it on the
                // first layout; it refreshes if the container resizes (rotation, keyboard, iPad split
                // resize). Because `proxy.size.height` never depends on the card, updating this can't
                // re-drive the measurement — no cycle.
                .onChange(of: proxy.size.height, initial: true) { _, height in
                    availableHeight = height
                }
                // Stream this composer's draft to the session's host (throttled in the store)
                // so every other device viewing this chat sees it live and locks its own
                // composer; an emptied field — including the clear on send — ends the typing
                // and unlocks them. Leaving the detail ends it too.
                .onChange(of: draft) { _, text in
                    store.composerDraftChanged(sessionID, text: text)
                    // Durably save the draft (cleared once the field empties, e.g. on send) so it
                    // survives leaving the app — see `RemoteStore.setSessionDraft`.
                    store.setSessionDraft(sessionID, text)
                }
                .onDisappear { store.composerDraftEnded(sessionID) }
        }
    }

    /// The live view of another device's in-progress draft for this chat, shown above the
    /// (locked) field. Head-truncated: the tail is where the typing is happening, so it's the
    /// part that must stay visible.
    private func remoteTypingRow(_ typing: ComposerTypingState) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 6) {
                Image(systemName: "ellipsis.bubble")
                Text("\(typerName(typing)) is typing…")
                Spacer(minLength: 0)
            }
            .font(.footnote.weight(.semibold))
            .foregroundStyle(Palette.accent)
            if !typing.text.isEmpty {
                Text(typing.text)
                    .font(.footnote)
                    .foregroundStyle(.secondary)
                    .lineLimit(3)
                    .truncationMode(.head)
                    .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 8).padding(.vertical, 6)
        .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10))
    }

    /// ⌘. interrupts a running session (the Mac's "stop" convention) — the action is otherwise
    /// only in the ⋯ menu. A hidden button carries the shortcut; present only when it applies.
    @ViewBuilder
    private var interruptShortcut: some View {
        if store.canControl, summary?.status == .running {
            Button("Interrupt") { store.interrupt(sessionID) }
                .keyboardShortcut(".", modifiers: .command)
                .opacity(0)
                .accessibilityHidden(true)
        }
    }

    /// Whether this session's project is under Nucleic Control (gates Orchestra + autoship).
    private var controlled: Bool {
        store.dashboard.projects.first { $0.id.rawValue == summary?.projectID }?.isNucleicControlled ?? false
    }

    private var modelBinding: Binding<String?> {
        Binding(get: { summary?.model }, set: { store.setSessionModel(sessionID, $0) })
    }
    private var effortBinding: Binding<String> {
        Binding(
            get: {
                let fallback = store.modelCatalog.fallbackEffort.isEmpty
                    ? MobileEfforts.fallback : store.modelCatalog.fallbackEffort
                return summary?.effort ?? fallback
            },
            set: { store.setSessionEffort(sessionID, $0) })
    }

    /// Memo for the context-occupancy scan below. The backward scan usually stops at the last
    /// turn's usage event, but a stream with sparse (or no) usage reporting walks the whole
    /// transcript — and `body` re-evaluates on every store change and keystroke, so an O(N) scan
    /// per evaluation quietly compounds on long sessions. `(count, lastSeq)` pins the stream, as
    /// in the projection cache; a class held in `@State` so updating it from `body` doesn't
    /// itself invalidate the view.
    private final class ContextScanCache {
        var count = -1
        var lastSeq: UInt64 = 0
        var used: Int?
    }
    @State private var contextScanCache = ContextScanCache()

    /// Live context-window occupancy (newest turn's input tokens ÷ the model's window), read
    /// from the transcript exactly as the Mac header does. Only the token scan is memoized —
    /// the window division stays live, so a model switch reflects immediately.
    private var contextPercent: Int? {
        let events = store.openEvents
        let cache = contextScanCache
        if cache.count != events.count || cache.lastSeq != (events.last?.seq ?? 0) {
            cache.count = events.count
            cache.lastSeq = events.last?.seq ?? 0
            cache.used = events.reversed().lazy.compactMap { event -> Int? in
                switch event.kind {
                case .turnCompleted(let turn): return turn.usage?.contextInputTokens
                case .usage(let usage): return usage.contextInputTokens
                default: return nil
                }
            }.first { $0 > 0 }
        }
        guard let used = cache.used else { return nil }
        let window = store.modelCatalog.contextWindow(summary?.model)
        guard window > 0 else { return nil }
        return min(100, Int((Double(used) / Double(window)) * 100))
    }

    /// The context-occupancy readout and the Auto toggle for the open session, folded into the
    /// glass chat bar so they don't cost a strip of transcript height. The model / effort
    /// selectors used to sit here too, but they moved into the ⋯ menu (`modelEffortMenu`) to keep
    /// the bar compact. Reads current state from the summary; each change is a control intent.
    @ViewBuilder
    private func controlRow(_ summary: WireSessionSummary) -> some View {
        HStack(spacing: 10) {
            if let percent = contextPercent {
                // Weight and primary ink (not `.secondary`) so the metric stays legible over
                // whatever the Liquid Glass bar is blurring through.
                Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
                    .font(.caption2.weight(.semibold)).foregroundStyle(.primary)
                    .help("Context window used")
            }
            Spacer(minLength: 4)
            // Locked on for a Nucleic Control project — those chats always run autonomously, and
            // the host keeps Auto on regardless of what the phone sends, so show it locked on to
            // match rather than offering a toggle that wouldn't take.
            Toggle(isOn: Binding(get: { controlled || summary.auto },
                                 set: { store.setSessionAuto(sessionID, $0) })) {
                Label("Auto", systemImage: (controlled || summary.auto) ? "bolt.fill" : "bolt.slash")
            }
            .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold))
            .disabled(controlled)
            .help(controlled
                ? "Auto-approve is always on for Nucleic Control chats — they run autonomously."
                : "Auto-approve safe actions; destructive ones still ask.")
        }
    }

    /// The ⋯ menu: the diff viewer for everyone, then — at control scope — the next-turn model /
    /// effort selectors (moved off the composer to keep it compact) and the session actions.
    @ViewBuilder
    private var sessionMenu: some View {
        Menu {
            Button { showDiff = true } label: {
                Label(diffLabel, systemImage: "plus.forwardslash.minus")
            }
            if let summary, store.canControl {
                Divider()
                modelEffortMenu(summary)
                Divider()
                sessionActions(summary)
            }
        } label: {
            Image(systemName: "ellipsis.circle")
        }
    }

    /// The model and effort selectors for the next turn, relocated from the composer's control row
    /// into the ⋯ menu so the glass chat bar stays compact — each opens as a submenu of levels.
    /// `ModelMenu` / `EffortMenu` are themselves menus, so they nest cleanly here.
    @ViewBuilder
    private func modelEffortMenu(_ summary: WireSessionSummary) -> some View {
        // A session's backend is fixed at creation, so only same-backend models are offered.
        ModelMenu(model: modelBinding, catalog: store.modelCatalog, backend: summary.backend)
        EffortMenu(effort: effortBinding, catalog: store.modelCatalog,
                   // No explicit model yet → use the session backend's default, so the menu
                   // shows that backend's effort range rather than collapsing to one level.
                   modelSKU: summary.model ?? store.modelCatalog.models(for: summary.backend).first?.sku,
                   controlled: controlled,
                   // Orchestra is a one-way latch for an existing chat — the host won't turn it
                   // off, so disable the other levels here to match.
                   lockWhenOrchestra: true)
    }

    /// "View changes" with the live +/− counts when the host projects them, so the menu hints
    /// whether there's anything to look at before opening the sheet.
    private var diffLabel: String {
        guard let stat = summary?.diffStat, stat.filesChanged > 0 else { return "View changes" }
        return "View changes · +\(stat.added) −\(stat.removed)"
    }

    /// The diff, presented as a sheet from the ⋯ menu — a viewer you visit, not a mode the
    /// chat lives in.
    private var diffSheet: some View {
        NavigationStack {
            Group {
                if store.capabilities.canFetchDiff || store.openDiff != nil {
                    SessionDiffView(diff: store.openDiff, loading: store.diffLoading)
                        .onAppear { store.fetchDiff(sessionID) }
                } else {
                    // Older host with no on-demand diff — the stat summary is all it projects.
                    DiffSummaryView(diffStat: summary?.diffStat)
                }
            }
            .navigationTitle("Changes")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .topBarTrailing) {
                    Button("Done") { showDiff = false }
                }
            }
        }
    }

    @ViewBuilder
    private func sessionActions(_ summary: WireSessionSummary) -> some View {
        // "Awaiting Input" that's actually done: flip it to "Done" by hand so a chat stuck on the
        // wrong state clears without spending another turn on it. Offered only while the gate holds
        // (`store.canMarkDone`), mirroring the Mac sidebar's own condition.
        if store.canMarkDone(summary) {
            Button { store.markSessionDone(sessionID) } label: {
                Label("Mark Done", systemImage: "checkmark.circle")
            }
        }
        Button { renameDraft = summary.title; showRename = true } label: {
            Label("Rename", systemImage: "pencil")
        }
        Button { store.setFavorite(sessionID, !summary.favorite) } label: {
            Label(summary.favorite ? "Unfavorite" : "Favorite",
                  systemImage: summary.favorite ? "star.slash" : "star")
        }
        // Autoship is a Nucleic Control capability — offered only for Control projects, and
        // the host couples it with auto-approval (enabling it turns Auto on).
        if controlled {
            Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
                Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
                      systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
            }
        }
        if summary.status == .running {
            Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") }
        }
        Section {
            Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") }
            Button(role: .destructive) { showDiscard = true } label: {
                Label("Discard…", systemImage: "xmark.bin")
            }
            Button { store.setArchived(sessionID, !summary.archived) } label: {
                Label(summary.archived ? "Unarchive" : "Archive",
                      systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
            }
        }
        Button(role: .destructive) { store.deleteSession(sessionID) } label: {
            Label("Delete", systemImage: "trash")
        }
    }

    /// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
    /// indicator, the session controls, and the composer — or the approval card when a gate is
    /// pending. Content scrolls beneath it; nothing renders when there's nothing to act on.
    private var actionArea: some View {
        VStack(spacing: 8) {
            // Interacting with a chat while the owning Mac is unreachable surfaces this first, so a
            // disabled composer reads as "offline / read-only history" rather than broken.
            if !store.connectivity.isLive { disconnectedBanner }
            // The last run died on an auth failure and the host can broker a sign-in — the
            // phone analogue of the Mac's in-transcript "Log in" row (docs/REMOTE_AGENT_LOGIN.md).
            if let summary {
                AgentAuthErrorBanner(sessionID: sessionID, backend: summary.backend)
            }
            actionContent
        }
        .padding(.horizontal, 12)
        .padding(.bottom, 8)
        // The jump-to-bottom chevron floats just above the chat bar as an *overlay* — deliberately
        // not a stack member. This whole action area is the transcript's bottom `safeAreaInset`,
        // so a stack-member chevron changed the scroll view's bottom inset by ~38pt every time it
        // appeared — and its visibility is *decided by* that same scroll geometry
        // (`isScrolledToBottom`). That geometry→chevron→geometry cycle could oscillate every
        // frame near the follow threshold, re-layouting the whole eager transcript each time and
        // pinning the main thread at 100% with the UI locked. An overlay contributes nothing to
        // the inset height, so showing or hiding it can't move the scroll geometry at all.
        // (It's not a transcript overlay either — that aligned to the scroll view's full-height
        // bounds and sat behind this floating bar at the screen's bottom edge.) A tap bumps the
        // same scroll request the send button uses, so following resumes once the transcript
        // reaches the bottom. Mirrors the Mac's `JumpToBottomButton`.
        .overlay(alignment: .top) {
            if !isScrolledToBottom {
                JumpToBottomButton { scrollToBottomRequest += 1 }
                    // Hang the button fully *above* the bar: top-aligned, then shifted up by its
                    // own 30pt height plus an 8pt gap. A render-time offset (not an alignment
                    // guide, which misplaced it half-overlapping the bar's top edge) so the
                    // placement is exact and — critically — contributes nothing to the inset.
                    .offset(y: -38)
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .animation(.easeInOut(duration: 0.15), value: isScrolledToBottom)
    }

    /// The offline notice over the transcript: the chat's history is cached and readable, but the
    /// composer + controls below stay disabled until the Mac that owns it is reachable again.
    private var disconnectedBanner: some View {
        HStack(spacing: 10) {
            Image(systemName: "wifi.slash").foregroundStyle(.secondary)
            VStack(alignment: .leading, spacing: 1) {
                Text("Disconnected").font(.footnote.weight(.semibold))
                Text("Showing saved history · reconnect to send")
                    .font(.caption2).foregroundStyle(.secondary)
            }
            Spacer(minLength: 8)
            Button("Reconnect") { store.reconnect() }
                .font(.caption.weight(.semibold))
                .buttonStyle(.borderless)
                .disabled(!store.isPaired)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 14).padding(.vertical, 10)
        .glassSurface(cornerRadius: 20)
    }

    @ViewBuilder
    private var actionContent: some View {
        Group {
            if let approval = store.openApprovals.first {
                // AskUserQuestion isn't a permission gate — it collects the user's answers and
                // needs them folded back into `updatedInput`. Render the picker (matching the
                // Mac's `AskUserQuestionBar`) instead of a plain allow/deny card, which would
                // reply with no selection and make the CLI report the question went unanswered.
                if approval.toolName == AskUserQuestion.toolName,
                    let questions = AskUserQuestion.questions(from: approval.input)
                {
                    AskUserQuestionCardView(
                        approval: approval, questions: questions, availableHeight: availableHeight)
                } else if approval.toolName == ExitPlanMode.toolName {
                    ExitPlanModeApprovalCardView(
                        approval: approval,
                        plan: ExitPlanMode.plan(from: approval.input),
                        availableHeight: availableHeight)
                } else {
                    ApprovalCardView(approval: approval, availableHeight: availableHeight)
                        // So a host_exec approval's working-directory line abbreviates the "cd"
                        // target the same way the settled transcript card does.
                        .environment(\.transcriptWorktreeRoot,
                                     TranscriptProjection.worktreeRoot(in: store.openEvents[...]))
                }
            } else if store.openApprovalLoading {
                // Opened from a Live Activity / notification for a session blocked on approval, but
                // the channel is still (re)connecting — bridge the gap with a placeholder so the tap
                // lands on the request-in-progress, not a blank transcript. Replaced by the real
                // ApprovalCardView the instant the snapshot arrives (sub-second reconnect).
                approvalLoadingCard
            } else if let status = summary?.status {
                let running = status == .running
                let canCompose = running || status == .awaitingInput || status.isTerminal
                let queued = summary?.queuedMessages ?? []
                if canCompose || store.canControl || !queued.isEmpty {
                    VStack(spacing: 10) {
                        // Follow-ups submitted mid-turn, held by the host to send when the current
                        // turn finishes. Each is shown as its own pill (with its attachments) and —
                        // at control scope — can be cancelled individually, matching the Mac
                        // composer. (They still send as one combined turn.)
                        if !queued.isEmpty {
                            VStack(spacing: 6) {
                                ForEach(queued) { message in
                                    queuedMessageRow(message)
                                }
                            }
                        }
                        if running {
                            HStack(spacing: 8) {
                                ProgressView().controlSize(.small)
                                Text(progressText).font(.footnote).foregroundStyle(.primary)
                                    // Cross-fade the label as the agent moves between steps, so a
                                    // fast run of tool calls doesn't snap the row's text.
                                    .contentTransition(.opacity)
                                    .animation(.easeInOut(duration: 0.15), value: progressText)
                                Spacer()
                            }
                            .frame(maxWidth: .infinity, alignment: .leading)
                        }
                        if store.canControl, let summary { controlRow(summary) }
                        if canCompose {
                            // Someone is typing in this chat on another device: their draft
                            // streams in live here while the field below is locked (one typer
                            // per session at a time, mirroring the Mac composer).
                            if let typing = remoteTyping {
                                remoteTypingRow(typing)
                            }
                            // Staged attachments ride above the field, matching the queued-message
                            // chips and the Mac composer.
                            if !attachments.isEmpty {
                                StagedAttachmentBar(attachments: $attachments)
                            }
                            if attachmentsOverflowed {
                                Text("Some files were too large to attach.")
                                    .font(.caption2).foregroundStyle(.secondary)
                                    .frame(maxWidth: .infinity, alignment: .leading)
                            }
                            HStack(alignment: .bottom, spacing: 8) {
                                // Attach photos/files to the next turn (control scope only — a
                                // view-only device can't send).
                                if store.canControl {
                                    AttachMenuButton(
                                        attachments: $attachments, overflowed: $attachmentsOverflowed)
                                        .disabled(!store.connectivity.isLive)
                                }
                                // No keyboard-accessory Done button here (it floats awkwardly
                                // over the glass bar on iOS 26) — a drag on the transcript
                                // dismisses the keyboard instead (`scrollDismissesKeyboard`).
                                TextField(
                                    remoteTyping.map { "\(typerName($0)) is typing…" }
                                        ?? (running ? "Queue a follow-up…" : "Send a follow-up…"),
                                    text: $draft, axis: .vertical)
                                    .textFieldStyle(.plain)
                                    .lineLimit(1...4)
                                    .padding(.vertical, 3)
                                    // Locked while another device is typing here — the
                                    // mesh-wide "one typer per session" contract.
                                    .disabled(remoteTyping != nil)
                                // Stop the in-flight turn (the Mac's ⌘. / "Interrupt"). Shown only
                                // while running and at control scope; send stays at the far right so
                                // its position never shifts. Mirrors `interruptShortcut`.
                                if running && store.canControl {
                                    Button { store.interrupt(sessionID) } label: {
                                        Image(systemName: "stop.circle.fill")
                                            .font(.title2)
                                            .foregroundStyle(.secondary)
                                    }
                                    .disabled(!store.connectivity.isLive)
                                    .accessibilityLabel("Stop")
                                }
                                Button {
                                    store.sendInput(draft, attachments: attachments.wireAttachments,
                                                    to: sessionID)
                                    draft = ""
                                    attachments = []
                                    attachmentsOverflowed = false
                                    scrollToBottomRequest += 1
                                } label: {
                                    Image(systemName: "arrow.up.circle.fill")
                                        .font(.title2)
                                }
                                // An attachment-only follow-up (files, no typed text) is sendable —
                                // but never while another device is mid-draft here (locked).
                                .disabled((draft.trimmingCharacters(in: .whitespaces).isEmpty && attachments.isEmpty)
                                    || !store.connectivity.isLive || remoteTyping != nil)
                                // Hardware-keyboard send (Magic Keyboard on iPad), mirroring the
                                // Mac — plain Return stays newline in the multiline field.
                                .keyboardShortcut(.return, modifiers: .command)
                            }
                        }
                    }
                    .padding(.horizontal, 14)
                    .padding(.vertical, 12)
                    .glassSurface(cornerRadius: 26)
                }
            }
        }
    }

    /// The placeholder shown while an approval's details are still loading — mirrors the approval
    /// card's frame (orange warning header, glass surface) so the real `ApprovalCardView` swaps in
    /// without a jump. Non-actionable: the real approval id isn't known yet, so there's nothing to
    /// Allow/Deny until the snapshot lands.
    private var approvalLoadingCard: some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: 6) {
                Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
                Text("Permission requested").font(.subheadline.weight(.semibold))
            }
            HStack(spacing: 8) {
                ProgressView().controlSize(.small)
                Text("Connecting to your Mac to load this approval…")
                    .font(.footnote).foregroundStyle(.secondary)
                Spacer(minLength: 0)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 14).padding(.vertical, 12)
        .glassSurface(cornerRadius: 26)
    }

    /// A queued follow-up shown above the composer: its text (or attachment names) with the
    /// attachments as chips, and — at control scope — a ✕ that cancels just this message.
    @ViewBuilder
    private func queuedMessageRow(_ message: QueuedMessage) -> some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 6) {
                Image(systemName: "clock").foregroundStyle(.secondary)
                Text(queuedRowText(message)).lineLimit(1).truncationMode(.tail)
                    .foregroundStyle(.secondary)
                Spacer(minLength: 8)
                if store.canControl {
                    Button {
                        // Refill the composer for editing (text only — the phone can't stage the
                        // host's files) unless the user has already started typing; then drop it.
                        if draft.trimmingCharacters(in: .whitespaces).isEmpty { draft = message.text }
                        store.cancelQueuedMessage(sessionID, message.id)
                    } label: {
                        Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
                    }
                    .buttonStyle(.plain)
                    .disabled(!store.connectivity.isLive)
                }
            }
            .font(.footnote)
            if !message.attachments.isEmpty {
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 6) {
                        ForEach(message.attachments) { attachment in
                            Label(attachment.filename, systemImage: "doc")
                                .font(.caption2)
                                .lineLimit(1)
                                .padding(.horizontal, 7).padding(.vertical, 3)
                                .background(.quaternary, in: .capsule)
                        }
                    }
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding(.horizontal, 8).padding(.vertical, 6)
        .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 10))
    }

    /// The one-line label for a queued pill: the message text, or — for an attachment-only
    /// message — the attachment names (the chips below repeat them), so it's never blank.
    private func queuedRowText(_ message: QueuedMessage) -> String {
        if !message.text.isEmpty { return message.text }
        return message.attachments.map(\.filename).joined(separator: ", ")
    }

    /// A live, human label for what the agent is doing right now — "Reading…", "Running on a macOS
    /// VM…", "Thinking…" — derived from the newest transcript event, replacing a fixed "Agent is
    /// working…" that said nothing about a turn that may run for minutes.
    ///
    /// Derived on-device from the host's own event stream rather than read off a field in the
    /// summary: the phone already holds those events (they're what the transcript above renders),
    /// so the label tracks the turn at streaming speed instead of at summary-refresh speed, needs
    /// no wire field, and works against a host of any version. Mirrors the Mac's
    /// `SessionDetailView.progressText`; keep the two in step.
    private var progressText: String {
        // Blocked on a human, not the agent. The composer's working row is hidden behind the
        // approval card in that state, but a resolved-but-not-yet-cleared gate can flash through
        // here — say what's actually being waited on rather than claiming the agent is working.
        if let approval = store.openApprovals.first {
            switch approval.toolName {
            case AskUserQuestion.toolName: return "Waiting for answers…"
            case ExitPlanMode.toolName: return "Waiting for plan review…"
            default: return "Waiting for approval…"
            }
        }
        for event in store.openEvents.reversed() {
            // Extended-thinking progress pings (`system/thinking_tokens`) arrive as raw events
            // during the model's reasoning pause — often the long gap after a tool result, before
            // any visible output. Without this the walk skips every ping and lands back on the last
            // tool call, freezing the row on e.g. "Running…" for the whole pause, which reads as a
            // stall even though the model is working.
            if Self.isThinkingPing(event) { return "Thinking…" }
            switch event.kind {
            case .toolCallStarted(let call), .toolCallCompleted(let call):
                return Self.gerund(for: call.name)
            case .thinking:
                return "Thinking…"
            case .assistantText(let chunk) where chunk.isPartial:
                return "Writing…"
            case .toolResult:
                // The tool has finished (its result landed); the model is now working on what comes
                // next. Stop here rather than walking back to the call behind it and reporting a
                // completed tool as still "Running…".
                return "Working…"
            case .userText:
                // Nothing from the agent since the user's message.
                return "Working…"
            default:
                continue
            }
        }
        return "Working…"
    }

    /// Whether this is a Claude `system/thinking_tokens` progress ping — the mobile echo of
    /// `TranscriptProjection.parseThinkingTokens`, reduced to the "is it one" half (the phone's
    /// working row shows no token meter, so the counts themselves go unused).
    private static func isThinkingPing(_ event: AgentEvent) -> Bool {
        guard case .raw(let raw) = event.kind else { return false }
        return event.nativeType == "system/thinking_tokens"
            || raw.native["subtype"]?.stringValue == "thinking_tokens"
    }

    /// Turns a tool name into a present-progressive status line — "Read" → "Reading…". Unknown
    /// tools fall back to "Running <name>…" so we never guess a malformed gerund. Mirrors the Mac's
    /// `SessionDetailView.gerund(for:)`.
    private static func gerund(for name: String) -> String {
        switch name {
        case "Read":                                 return "Reading…"
        case "Write":                                return "Writing…"
        case "Edit", "MultiEdit", "NotebookEdit":    return "Editing…"
        case "Bash", "BashOutput":                   return "Running…"
        case "Grep", "Glob":                         return "Searching…"
        case "WebFetch":                             return "Fetching…"
        case "WebSearch":                            return "Searching the web…"
        case "Task":                                 return "Delegating…"
        case HostCommandSummary.hostExecToolName:    return "Running a command on host…"
        // The VM/container tools name where the work is happening ("Running on a macOS VM…")
        // rather than echoing their `mcp__nucleic__…` wire name.
        default:                                     return SandboxToolDisplay.gerund(for: name)
                                                         ?? "Running \(name)…"
        }
    }
}

struct TranscriptList: View {
    let events: [AgentEvent]
    /// Bumped by the parent when the user sends a message — a deliberate "show me what happens
    /// next" that jumps to the bottom even if they'd scrolled up to read history.
    var scrollToBottomRequest: Int = 0
    @AppStorage("nucleic.showRawEvents") private var showRaw = false
    @AppStorage("nucleic.showLockEvents") private var showLockEvents = true

    /// True while the user is parked at (within a hair of) the bottom, so live output keeps
    /// following; once they scroll up it flips false, following stops, and the jump-to-bottom
    /// chevron appears. Starts true — a freshly opened chat is anchored at the bottom. Owned by
    /// the parent so the chevron can live in the chat bar above the composer.
    @Binding var isScrolledToBottom: Bool

    /// True for a short window right after the transcript appears, while it runs its first
    /// layout passes and its events fill in. During it we accept only "at bottom" scroll
    /// readings, so the settling layout can't strand the chat above its true bottom or flash the
    /// chevron; live scrolls are honored once it clears. Mirrors the Mac's `transcriptSettling`.
    @State private var transcriptSettling = true

    /// Total scroll-content height, tracked live from the scroll view's geometry, but only while
    /// settling — a `.task(id:)` debounces it to detect when the opening layout has come to rest.
    @State private var transcriptContentHeight: CGFloat = 0

    /// Incremental transcript projection (docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md). Two layers:
    /// a read memo that collapses the redundant `body` re-evaluations (settling layout bumps
    /// `transcriptContentHeight` every frame while opening, and each bump re-runs `body` against
    /// an unchanged stream), and — when the stream *has* grown — a stable-prefix fold that seals
    /// everything before the live turn once and re-folds only the tail, so a streaming delta
    /// costs O(live-tail) instead of re-folding all N events (which made long sessions cost
    /// O(N²) over their lifetime). A class held in `@State` so reading/updating it from `body`
    /// doesn't itself invalidate the view (SwiftUI stores the reference, never diffs interior).
    @State private var projectionCache = IncrementalTranscriptProjection()

    /// The in-flight background Markdown pre-warm (below), cancelled when a newer one supersedes
    /// it or the transcript goes away — so a long warm can't outlive the view or stack up behind
    /// row churn.
    @State private var prewarmTask: Task<Void, Never>?

    /// How many projected rows the pre-warm has already covered, so each later trigger snapshots
    /// only the *new* rows instead of re-walking (and re-hashing) the whole transcript on every
    /// row that lands — which would quietly re-introduce an O(N) main-thread pass per row. Held
    /// in a box (not `@State` value) because updating it from `body`-adjacent code must not
    /// invalidate the view.
    private final class PrewarmProgress { var count = 0 }
    @State private var prewarmProgress = PrewarmProgress()

    private var items: [TranscriptItem] {
        projectionCache.items(for: events, showRaw: showRaw, showLockEvents: showLockEvents)
    }

    /// The session's worktree root — its `sessionStarted` cwd — read straight from the events (an
    /// early-returning scan) and injected into the transcript so a host card's working-directory
    /// line can abbreviate an absolute "cd" path to a short "<worktree name>/…" anchor.
    private var worktreeRoot: String? {
        TranscriptProjection.worktreeRoot(in: events[...])
    }

    /// Kick off (or restart) the off-main Markdown pre-warm for the visible messages. `MarkdownText`
    /// parses every prose line with `AttributedString(markdown:)` during the eager first layout — a
    /// cost that lands on the main thread right as the session is pushed and the tab bar slides
    /// away, jittering the load-in. Snapshot the message bodies here on the main actor (a cheap read
    /// of the memoized projection), then parse them on a background task so that first layout finds
    /// the caches already warm. Idempotent and self-cancelling; the parse results are the same
    /// whichever thread fills the (thread-safe) caches. Incremental: only rows beyond the last
    /// covered count are snapshotted (coalescing can shuffle nearby indices, but a missed body just
    /// parses on first layout as before — the warm is an optimization, never a correctness gate).
    private func prewarmMarkdown() {
        let current = items
        if current.count < prewarmProgress.count { prewarmProgress.count = 0 }  // stream reset
        let bodies: [String] = current[prewarmProgress.count...].compactMap {
            if case .message(_, let text) = $0.kind { return text } else { return nil }
        }
        prewarmProgress.count = current.count
        guard !bodies.isEmpty else { return }
        // Chain batches instead of cancelling the in-flight one: each batch covers *new* rows
        // only, so cancelling a predecessor (say, the big open batch, superseded by the first
        // streamed row) would permanently drop its coverage. `onDisappear` cancels the head of
        // the chain; a predecessor mid-parse just finishes its bounded batch into shared caches.
        prewarmTask = Task.detached(priority: .utility) { [previous = prewarmTask] in
            await previous?.value
            for body in bodies {
                if Task.isCancelled { return }
                MarkdownText.prewarm(body)
            }
        }
    }

    /// How much content may still sit below the viewport's bottom edge and still count as "at
    /// the bottom" — a little slack so streaming deltas and rounding never flip the gate. Mirrors
    /// the Mac's `bottomFollowThreshold`.
    private let bottomFollowThreshold: CGFloat = 24

    /// How far a *user* scroll must move away from the bottom before following disengages. Wider
    /// than the re-engage threshold above on purpose (hysteresis): the action bar's height isn't
    /// constant (composer lines grow, the working row appears, the keyboard dismisses
    /// interactively), and every inset change perturbs the scroll geometry by tens of points. A
    /// single threshold read both ways let one such perturbation flip the gate, whose reactions
    /// (anchor toggle, bar animation) perturbed the geometry again — an oscillation that
    /// re-layouted the whole eager transcript every frame and pinned the main thread at 100%.
    /// The band is wider than any bar-height delta, so only a deliberate scroll crosses it.
    private let bottomUnfollowThreshold: CGFloat = 64

    /// The live scroll phase, used to tell *user* scrolling (an active drag / flick decelerating)
    /// from programmatic motion (autoscroll animations, anchor re-pins, inset changes). Only a
    /// user-driven phase may disengage bottom-following — a programmatic perturbation can only
    /// ever re-engage it — which structurally breaks every geometry→state→geometry feedback
    /// cycle: no chain of layout reactions can take the gate false and sustain itself.
    ///
    /// `.tracking` (finger down, no displacement yet) deliberately does NOT count: a finger
    /// resting on the transcript scrolls nothing, but touching down *stops a live deceleration*,
    /// and that stop emits a final far-from-bottom geometry reading under `.tracking`. Tapping
    /// the chevron mid-deceleration raced exactly that emission against the tap's "follow again"
    /// — when the stop reading landed after it, following disengaged right back and the chevron
    /// stuck until a second tap. Real scroll-aways always pass through `.interacting`.
    @State private var scrollPhase: ScrollPhase = .idle
    private var isUserScrolling: Bool {
        scrollPhase == .interacting || scrollPhase == .decelerating
    }

    /// True from an explicit jump (chevron / send) until the scroll actually reaches the bottom.
    /// While set, the unfollow branch is suppressed entirely: phase changes and geometry
    /// emissions are delivered on separate callbacks with no ordering guarantee, so a stopping
    /// fling could emit one last far-from-bottom reading whose *recorded* phase was still
    /// user-driven (`.decelerating`) — landing after the tap's "follow again" and disengaging it,
    /// which left the chevron up until a second tap. The latch outlives any stale emission and
    /// clears on arrival at the bottom, or the moment the user genuinely grabs the transcript
    /// again (`.interacting`), so a mid-jump scroll-away still works.
    @State private var jumpingToBottom = false

    var body: some View {
        ScrollViewReader { proxy in
            ScrollView {
                // Eager VStack (not Lazy): the whole transcript is in memory, and eager layout
                // hands the ScrollView its true, *fixed* total content height up front. A
                // LazyVStack instead realizes rows as they scroll into view, so its reported
                // content height shifts mid-scroll as rows resolve — and that shifting height fed
                // straight back through the geometry reader below (flip at-bottom → toggle the
                // `.defaultScrollAnchor` → re-pin → new height → …), which is what made the whole
                // transcript jitter up and down under a drag. A stable height breaks the loop.
                // Matches the Mac transcript, for the same reason.
                VStack(alignment: .leading, spacing: 14) {
                    ForEach(items) { item in
                        TranscriptRow(item: item).id(item.id)
                    }
                    // Fixed, zero-content scroll target for "jump to bottom". Scrolling to the last
                    // row's id instead would chase a moving target — the final row's identity
                    // changes as its streaming deltas coalesce — nudging the viewport mid-stream.
                    Color.clear.frame(height: 1).id(Self.bottomAnchorID)
                }
                .environment(\.transcriptWorktreeRoot, worktreeRoot)
                .padding()
                // Cap the transcript to a readable measure on a wide iPad so lines don't run
                // edge-to-edge on a 13-inch screen; a no-op at phone width.
                .readableColumn()
            }
            // A drag on the transcript dismisses the keyboard, so a tall multiline composer can
            // be put away without leaving the session.
            .scrollDismissesKeyboard(.interactively)
            // The single-argument `defaultScrollAnchor(.bottom)` governs both the initial offset
            // and how the scroll view re-pins on content-size changes — it sticks the bottom edge
            // in view as the transcript grows. That native stickiness is what we want while the
            // user is parked at the bottom (the chat opens at the end and follows live output),
            // but it's also what yanked them back down whenever anything changed while they'd
            // scrolled up to read history. So gate the anchor itself: drop it to `nil` the moment
            // they scroll away, which lets new content land off-screen below instead of dragging
            // the viewport, and restore `.bottom` once they're back at the end.
            .defaultScrollAnchor(isScrolledToBottom ? .bottom : nil)
            .onScrollPhaseChange { _, newPhase in
                scrollPhase = newPhase
                // A real grab (drag displacement, not a mere touch-down) takes over from an
                // in-flight programmatic jump: the user may scroll away again immediately.
                if newPhase == .interacting { jumpingToBottom = false }
            }
            // Track the live scroll position straight from the scroll view's geometry: how much
            // content still sits below the viewport bottom. Parked within the follow threshold
            // means live output keeps following; a *user* scroll past the (wider) unfollow
            // threshold flips it false, which drops the anchor (above) and reveals the chevron.
            // Asymmetric on purpose — see `bottomUnfollowThreshold` / `isUserScrolling`: a
            // programmatic geometry change (anchor re-pin, bar resize, keyboard, autoscroll
            // animation) may re-engage following but can never disengage it, so no layout
            // feedback cycle through this gate can sustain itself. Tracking the rounded distance
            // (not a Bool) also means every scroll emits fresh values, so the gate can't latch
            // against a stale reading (the old settle-window latch bug).
            .onScrollGeometryChange(for: CGFloat.self) { geo in
                (geo.contentSize.height - geo.containerSize.height - geo.contentOffset.y).rounded()
            } action: { _, distance in
                if distance <= bottomFollowThreshold {
                    isScrolledToBottom = true
                    jumpingToBottom = false  // arrived — the jump is complete
                } else if !transcriptSettling, distance > bottomUnfollowThreshold, isUserScrolling,
                          !jumpingToBottom {
                    // While the chat is opening the layout grows over a few passes and the offset
                    // lags each growth by a frame — those frames read "not at bottom" even though
                    // `.defaultScrollAnchor(.bottom)` is about to re-pin, so settling accepts
                    // only re-engagement (the `!transcriptSettling` above).
                    isScrolledToBottom = false
                }
            }
            // Detect when the opening layout has come to rest: track the content height while
            // settling and let `.task(id:)` debounce it — the task restarts on every change and
            // only reaches its body once the height has held steady. Freezing the tracker after
            // settling keeps live streaming (which changes the height every frame) from re-arming.
            .onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height.rounded() } action: { _, height in
                if transcriptSettling { transcriptContentHeight = height }
            }
            .task(id: transcriptContentHeight) {
                guard transcriptSettling, transcriptContentHeight > 0 else { return }
                try? await Task.sleep(for: .milliseconds(80))
                if Task.isCancelled { return }
                // Pin to the true bottom before opening the gate. The settling layout lands a
                // hair off the bottom, so the passive `.defaultScrollAnchor` alone leaves the
                // scroll geometry reading "not at bottom" — and that stale reading latches
                // `onScrollGeometryChange`'s tracked value at `false` while the gate above holds
                // `isScrolledToBottom` at `true`. Because the callback only fires on a *change*,
                // the user's first real scroll-up (still `false`) then never fires it: the chevron
                // never appears and live output keeps yanking the view to the bottom, until a
                // down-then-up round-trip finally re-emits the change. An explicit pin nudges the
                // offset the last hair, forcing a fresh "at bottom" emission that re-syncs the
                // tracker with the gate. Mirrors the Mac's reveal pin.
                scrollToEnd(proxy, animated: false)
                transcriptSettling = false
            }
            // Coalescing means item count lags event count; key the autoscroll on the raw stream
            // so every streamed delta keeps the view pinned to the bottom — but only while the
            // user is already parked there. Scrolling up to read history is never yanked down,
            // and a finger actively on the transcript is never fought mid-drag (the drag that
            // takes them past the unfollow threshold flips the gate; until then the native
            // bottom anchor alone keeps content pinned, without a scroll grabbing the viewport
            // back out of their hand). Never animated: while pinned, the bottom anchor provides
            // the visual continuity and this call only closes the last few points — but a
            // session still *opening* keeps receiving history merges (cached transcript,
            // reconnect backfill) after the settle window closes, and animating those rode the
            // viewport visibly down through the whole transcript instead of landing at the end.
            .onChange(of: events.count) {
                if isScrolledToBottom, !isUserScrolling {
                    scrollToEnd(proxy, animated: false)
                }
            }
            // An explicit jump — the chevron or sending a message — always wins, and it *is* the
            // user saying "follow again": re-engage the gate directly rather than waiting for the
            // geometry to confirm. The animated scroll can come to rest a few points short of the
            // follow threshold (content padding, inset rounding), which sat inside the hysteresis
            // band — following never re-engaged and the chevron lingered until the user manually
            // scrolled the last few points. Setting the gate here hides the chevron immediately
            // and hands pinning back to the bottom anchor; a later real scroll-up still
            // disengages it as usual.
            .onChange(of: scrollToBottomRequest) {
                isScrolledToBottom = true
                jumpingToBottom = true
                scrollToEnd(proxy)
            }
            // Warm the Markdown parse caches off the main thread whenever the row set grows —
            // `initial: true` fires it for the batch that lands on open (the expensive case), and
            // each later new row tops it up. Keyed on the row *count*, so streaming deltas into an
            // existing row (which don't change the count) never re-arm it. `items.count` is O(1).
            .onChange(of: items.count, initial: true) { _, _ in prewarmMarkdown() }
            .onDisappear { prewarmTask?.cancel() }
        }
    }

    /// Scroll the fixed bottom anchor into view; `animated: false` during the open-settle
    /// window so a chat that's still laying out snaps to the tail instead of easing into place.
    private func scrollToEnd(_ proxy: ScrollViewProxy, animated: Bool = true) {
        if animated {
            withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }
        } else {
            proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom)
        }
    }

    private static let bottomAnchorID = "nucleic.transcript-bottom"
}

/// The floating "jump to the latest" chevron shown above the chat bar while the user has
/// scrolled up away from the bottom of the transcript. The mobile twin of the Mac's
/// `JumpToBottomButton`.
private struct JumpToBottomButton: View {
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            Image(systemName: "chevron.down")
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(Palette.accent)
                .frame(width: 30, height: 30)
                .background(.regularMaterial, in: .circle)
                .overlay(Circle().strokeBorder(.separator, lineWidth: 0.5))
                .shadow(color: .black.opacity(0.18), radius: 4, y: 1)
        }
        .buttonStyle(.plain)
        .accessibilityLabel("Jump to the latest message")
    }
}

/// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file
/// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's
/// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
/// wide detail column. Same wire, no new protocol.
struct SessionDiffView: View {
    let diff: WireSessionDiff?
    let loading: Bool
    /// Below this available width the two-pane diff would leave the patch too cramped, so we
    /// keep the phone stack. Picks two-pane on a wide iPad (landscape, or a large iPad) and the
    /// stack on a phone or a narrow portrait split — based on real width, not just size class.
    private let twoPaneMinWidth: CGFloat = 700

    var body: some View {
        if let diff, diff.stat.filesChanged > 0 {
            GeometryReader { geo in
                if geo.size.width >= twoPaneMinWidth {
                    SplitDiffView(diff: diff)
                } else {
                    stacked(diff)
                }
            }
        } else if loading {
            ProgressView("Fetching diff…")
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        } else {
            ContentUnavailableView("No changes yet", systemImage: "doc")
        }
    }

    /// Phone form: the file summary over one scrolling unified patch.
    private func stacked(_ diff: WireSessionDiff) -> some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 14) {
                VStack(alignment: .leading, spacing: 6) {
                    DiffStatHeader(stat: diff.stat)
                    ForEach(diff.files) { DiffFileRow(file: $0) }
                }
                .card()
                PatchText(patch: diff.patch)
                if diff.truncated { TruncatedPatchNote() }
            }
            .padding()
            .readableColumn()
        }
    }
}

/// iPad form: the Mac's two-pane diff. A tappable file list on the left drives the selected
/// file's patch on the right. Row taps use a plain `Button` (not `List(selection:)`, whose
/// single-select tap handling is unreliable outside an edit-mode / split-view context).
private struct SplitDiffView: View {
    let diff: WireSessionDiff
    @State private var selected: String?

    /// The combined patch split into per-file sections, keyed by file path.
    private var sections: [String: String] { UnifiedPatch.sections(diff.patch, files: diff.files) }
    private var current: String? { selected ?? diff.files.first?.path }

    var body: some View {
        HStack(spacing: 0) {
            fileList
                .frame(width: 280)
                .background(Color(.secondarySystemBackground))
            Divider()
            patchPane
                .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .onAppear { if selected == nil { selected = diff.files.first?.path } }
        // Keep the selection valid as the diff refetches (files can appear/vanish between turns).
        .onChange(of: diff.files) { _, files in
            if selected == nil || !files.contains(where: { $0.path == selected }) {
                selected = files.first?.path
            }
        }
    }

    private var fileList: some View {
        ScrollView {
            LazyVStack(alignment: .leading, spacing: 0) {
                DiffStatHeader(stat: diff.stat)
                    .padding(.horizontal, 12).padding(.vertical, 10)
                    .frame(maxWidth: .infinity, alignment: .leading)
                Divider()
                ForEach(diff.files) { file in
                    Button { selected = file.path } label: {
                        DiffFileRow(file: file)
                            .padding(.horizontal, 12).padding(.vertical, 9)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .background(current == file.path
                                ? Palette.accent.opacity(0.15) : Color.clear)
                            .contentShape(Rectangle())
                    }
                    .buttonStyle(.plain)
                    .hoverEffect(.highlight)
                }
                if diff.truncated { TruncatedPatchNote().padding(12) }
            }
        }
    }

    @ViewBuilder
    private var patchPane: some View {
        if let path = current, let patch = sections[path], !patch.isEmpty {
            ScrollView { PatchText(patch: patch).padding() }
        } else if current != nil {
            // A file with no textual hunk (binary, or a section dropped by patch truncation).
            ContentUnavailableView(
                "No preview", systemImage: "doc",
                description: Text("This file has no textual diff\(diff.truncated ? " in the fetched patch" : "").")
            )
        } else {
            ContentUnavailableView("Select a file", systemImage: "sidebar.left")
        }
    }
}

/// The "N files changed · +A −R" header shared by both diff forms.
private struct DiffStatHeader: View {
    let stat: DiffStat
    var body: some View {
        Text("\(stat.filesChanged) file\(stat.filesChanged == 1 ? "" : "s") changed · +\(stat.added) −\(stat.removed)")
            .font(.subheadline.weight(.semibold))
    }
}

/// One file's row: status glyph, path, and its +/− counts. Shared by the phone summary and the
/// iPad file list.
struct DiffFileRow: View {
    let file: WireFileDiff
    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: DiffStatus.icon(file.status))
                .font(.caption)
                .foregroundStyle(DiffStatus.color(file.status))
            Text(file.path)
                .font(.caption.monospaced())
                .lineLimit(1).truncationMode(.middle)
            Spacer(minLength: 8)
            Text("+\(file.added)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.success)
            Text("−\(file.removed)").font(.caption2.monospacedDigit()).foregroundStyle(Palette.danger)
        }
    }
}

enum DiffStatus {
    static func icon(_ status: String) -> String {
        switch status {
        case "added", "untracked": "plus.circle"
        case "deleted": "minus.circle"
        case "renamed": "arrow.right.circle"
        default: "pencil.circle"
        }
    }
    static func color(_ status: String) -> Color {
        switch status {
        case "added", "untracked": Palette.success
        case "deleted": Palette.danger
        default: .secondary
        }
    }
}

private struct TruncatedPatchNote: View {
    var body: some View {
        Label("Patch truncated — open the Mac for the rest.", systemImage: "scissors")
            .font(.caption).foregroundStyle(.secondary)
            .frame(maxWidth: .infinity, alignment: .leading)
    }
}

/// Splits a combined `git diff` patch into per-file sections for the iPad's two-pane view.
enum UnifiedPatch {
    /// Map from file path to that file's slice of the unified patch. Sections begin at a
    /// `diff --git a/… b/…` line. When the section count matches `files` we pair positionally
    /// (the host emits patch and files together, in order); otherwise we key each section by the
    /// new path parsed from its header. Callers fall back to a "no preview" state on a miss.
    static func sections(_ patch: String, files: [WireFileDiff]) -> [String: String] {
        guard patch.contains("diff --git ") else {
            // A single-file patch with no git header: attribute the whole thing to the sole file.
            return files.count == 1 ? [files[0].path: patch] : [:]
        }
        var chunks: [String] = []
        var current: [Substring] = []
        for line in patch.split(separator: "\n", omittingEmptySubsequences: false) {
            if line.hasPrefix("diff --git ") {
                if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }
                current = [line]
            } else if !current.isEmpty {
                current.append(line)
            }
        }
        if !current.isEmpty { chunks.append(current.joined(separator: "\n")) }

        var result: [String: String] = [:]
        if chunks.count == files.count {
            for (file, chunk) in zip(files, chunks) { result[file.path] = chunk }
        } else {
            for chunk in chunks where newPath(chunk) != nil { result[newPath(chunk)!] = chunk }
        }
        return result
    }

    /// The new-side path from a section's `diff --git a/OLD b/NEW` header. Nil when the header is
    /// absent or the path is quoted/spaced in a way we don't split cleanly.
    private static func newPath(_ chunk: String) -> String? {
        guard let header = chunk.split(separator: "\n", maxSplits: 1).first,
              header.hasPrefix("diff --git "),
              let bRange = header.range(of: " b/") else { return nil }
        let path = header[bRange.upperBound...]
        return path.isEmpty ? nil : String(path)
    }
}

/// The unified patch, one `Text` per line with the classic +/− coloring. Lines scroll
/// horizontally as a block so long lines don't wrap into noise.
private struct PatchText: View {
    let patch: String

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            VStack(alignment: .leading, spacing: 0) {
                ForEach(Array(patch.components(separatedBy: "\n").enumerated()), id: \.offset) { _, line in
                    Text(line.isEmpty ? " " : line)
                        .font(.caption.monospaced())
                        .foregroundStyle(lineColor(line))
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .background(lineBackground(line))
                }
            }
            .textSelection(.enabled)
            .padding(8)
        }
        .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
    }

    private func lineColor(_ line: String) -> Color {
        if line.hasPrefix("+++") || line.hasPrefix("---") { return .secondary }
        if line.hasPrefix("+") { return Palette.success }
        if line.hasPrefix("-") { return Palette.danger }
        if line.hasPrefix("@@") { return Palette.active }
        if line.hasPrefix("diff ") { return .secondary }
        return Palette.primaryText
    }

    private func lineBackground(_ line: String) -> Color {
        if line.hasPrefix("+++") || line.hasPrefix("---") { return .clear }
        if line.hasPrefix("+") { return Palette.success.opacity(0.10) }
        if line.hasPrefix("-") { return Palette.danger.opacity(0.10) }
        return .clear
    }
}

struct DiffSummaryView: View {
    let diffStat: DiffStat?

    var body: some View {
        VStack(spacing: 12) {
            if let diff = diffStat, diff.filesChanged > 0 {
                Label("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed", systemImage: "doc.text")
                    .font(.headline)
                HStack(spacing: 16) {
                    Text("+\(diff.added)").foregroundStyle(.green)
                    Text("−\(diff.removed)").foregroundStyle(.red)
                }
                .font(.title3.monospacedDigit())
                Text("Open the Mac for the full patch.")
                    .font(.footnote).foregroundStyle(.secondary)
            } else {
                ContentUnavailableView("No changes yet", systemImage: "doc")
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .padding()
    }
}
