import SwiftUI
import NucleicCore

// How one nash emission reads in the Nash Viewer — the words, the direction, and the accent.
//
// Presentation lives here rather than on `NashEvent` so the model keeps only the structured facts
// nash reported. The organizing idea: nash's whole point is that it sees *data moving*, so the
// three kinds that carry bytes (redirect, `$(…)`, pipe) all render as the same `source ──▸ dest`
// flow with the payload one click away, and the kinds that merely narrate shell state stay quiet
// and gray behind them.

// MARK: - Flow shapes

/// A redirection read as a sentence: which way the bytes went, between what, and under which verb.
///
/// nash reports the operator as written (`>`, `>>`, `2>`, `<<<`); an operator is not a sentence, so
/// this turns it into one. The fd becomes its stream name and the target its file name, which is
/// what makes `2> /tmp/build/err.log` read as "stderr ▸ err.log · overwrote".
struct NashRedirectFlow {
    /// Which end the bytes started at. Drives the arrow and which side the file sits on.
    enum Direction { case output, input }

    let direction: Direction
    let source: String
    let destination: String
    /// Plain-English operator ("overwrote", "appended to", "read from").
    let verb: String
    /// Full path when the target is a real file — the second line, since the flow line shows only
    /// the file name.
    let path: String?

    init(op: String, fd: Int, target: String?) {
        // nash prefixes the fd onto the operator for stderr (`2>`); the fd is already a field, so
        // match on the operator alone.
        let bare = op.drop { $0.isNumber }
        let isInput = bare.hasPrefix("<")
        direction = isInput ? .input : .output

        let stream = switch fd {
        case 0: "stdin"
        case 1: "stdout"
        case 2: "stderr"
        default: "fd \(fd)"
        }
        // Heredocs and here-strings have no path — their body *is* the source, so name the shape.
        let other = target.map { ($0 as NSString).lastPathComponent }
            ?? (bare == "<<" ? "here-doc" : bare == "<<<" ? "here-string" : "inline data")
        source = isInput ? other : stream
        destination = isInput ? stream : other
        path = target

        verb = switch bare {
        case ">", ">|": "overwrote"
        case ">>": "appended to"
        case "<": "read from"
        case "<>": "read and wrote"
        case "<<": "fed a here-doc to"
        case "<<<": "fed a here-string to"
        default: "wrote to"
        }
    }
}

/// One pipe link read as a sentence: the command that produced the bytes and the one that consumed
/// them.
///
/// Both names come off the link itself — brush-core renders each stage from its AST when it spawns
/// the tee. That matters twice over: a stage that is a compound command (`{ a; b; } | c`) emits one
/// exec event per command inside it and none of them says which owned the pipe, and a link is
/// reported the moment its producer closes, which can beat that producer's own exec event to the
/// host. Deriving the names from exec events could get either wrong; rendering the stage cannot.
///
/// The exec-derived `stages` remain the fallback for a pre-`fromText` nash, the source of each
/// stage's exit code, and the only way to name the stages this link does *not* touch.
struct NashPipeFlow {
    let producer: String
    let consumer: String
    let fromIndex: Int
    let toIndex: Int
    let pipelineID: Int
    let stages: [Int: NashPipelineStage]

    private let fromText: String
    private let toText: String

    init(
        pipelineID: Int, fromIndex: Int, toIndex: Int, fromText: String, toText: String,
        stages: [Int: NashPipelineStage]
    ) {
        self.pipelineID = pipelineID
        self.fromIndex = fromIndex
        self.toIndex = toIndex
        self.fromText = fromText
        self.toText = toText
        self.stages = stages
        producer = Self.name(fromText, stages[fromIndex], index: fromIndex)
        consumer = Self.name(toText, stages[toIndex], index: toIndex)
    }

    /// The command at `index`. This link's own two ends are named by the text it carries; the rest
    /// of the chain falls back to the exec events, which arrive as those stages exit.
    func name(at index: Int) -> String {
        switch index {
        case fromIndex: producer
        case toIndex: consumer
        default: Self.name("", stages[index], index: index)
        }
    }

    /// Whether `name(at:)` is a real command rather than a `stage N` placeholder. Keyed on the text
    /// actually present, not on the index: against a nash that predates stage text, this link's own
    /// ends are placeholders too until their exec events land.
    func isNamed(at index: Int) -> Bool {
        if stages[index] != nil { return true }
        switch index {
        case fromIndex: return !fromText.isEmpty
        case toIndex: return !toText.isEmpty
        default: return false
        }
    }

    private static func name(_ text: String, _ stage: NashPipelineStage?, index: Int) -> String {
        if !text.isEmpty { return text }
        return stage?.command ?? "stage \(index + 1)"
    }

    /// Total stages, from whichever stage reported the pipeline's length.
    var length: Int? { stages.values.first?.length }
}

// MARK: - Presentation

extension NashEvent.Kind {
    /// Categorical accent. Only the three data-flow kinds get a hue of their own — those are what
    /// the panel exists to show. The shell-activity kinds stay neutral so a burst of `exec` rows
    /// can't drown out the one `pipe` row you opened the panel to find, and the two problem kinds
    /// take the palette's warning colors.
    func accent(_ palette: AppPalette) -> Color {
        switch self {
        case .redirect: palette.accent
        case .pipe: palette.active
        case .cmdsub: palette.paused
        case .exec, .cd, .export: palette.neutral
        case .fallback: palette.attention
        case .dropped: palette.danger
        // A refused lever is the strongest signal in this feed — something tried to switch its own
        // observation off — so it takes the danger accent rather than the softer attention one.
        case .policy: palette.danger
        }
    }
}

extension NashEvent {
    /// Everything worth matching a search against — including the facts the row shows as structure
    /// rather than text (a redirect's full path, a pipe's stage commands) and nash's payload hash,
    /// so searching for what you can see always works.
    func matches(_ needle: String, pipeStages: [Int: NashPipelineStage]) -> Bool {
        if summary.localizedCaseInsensitiveContains(needle) { return true }
        if detail?.localizedCaseInsensitiveContains(needle) == true { return true }
        if preview?.localizedCaseInsensitiveContains(needle) == true { return true }
        if hash?.localizedCaseInsensitiveContains(needle) == true { return true }
        return pipeStages.values.contains { $0.command.localizedCaseInsensitiveContains(needle) }
    }

    /// The one-line taste of the payload shown on a collapsed row: its first non-blank line. Enough
    /// to recognize `{"token":…` or `#!/bin/sh` without opening anything.
    var previewTeaser: String? {
        guard let preview else { return nil }
        guard let line = preview.split(whereSeparator: \.isNewline)
            .first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty })
        else { return nil }
        return String(line.prefix(200))
    }
}

/// Environment-variable names that read as credential-shaped. nash reports names but never values
/// (docs/NASH.md §5.4) precisely so this classification can happen host-side without secret risk —
/// the name alone is the signal worth flagging.
private let sensitiveNameMarkers = ["SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "APIKEY", "PRIVATE"]

private func looksSensitive(_ name: String) -> Bool {
    let upper = name.uppercased()
    if sensitiveNameMarkers.contains(where: upper.contains) { return true }
    // `_KEY`/`KEY_` but not `KEYBOARD` or `MONKEY`.
    return upper.hasSuffix("_KEY") || upper.hasPrefix("KEY_") || upper == "KEY"
}

// MARK: - Row

/// One emission on **one line**: time, a shell-native glyph for the kind, what happened, and a
/// trailing metric. Everything else — the working directory, exit code, full target path, hash,
/// pipeline slot — lives in the expansion.
///
/// The one-line rule is the whole layout. A log is scanned vertically, so every line a row spends
/// is a row the reader doesn't see; `pwd -P` earning three lines (and repeating the same worktree
/// path on all of them) buried the handful of rows that actually matter. The only row that spends a
/// second line is one carrying intercepted bytes, which is exactly the row worth spending it on.
struct NashEventRow: View {
    let event: NashEvent
    /// Resolved stage commands, for a `pipe` row. Empty for every other kind.
    let pipeStages: [Int: NashPipelineStage]
    /// The session's worktree. A command's cwd is shown only when it *isn't* here — otherwise every
    /// row repeats the one path the whole panel is already scoped to.
    let worktreeRoot: String?
    /// Only worth a glyph when the session has more than one emitter.
    let showsEnvironment: Bool
    let isExpanded: Bool
    let toggle: () -> Void

    @Environment(\.appPalette) private var palette
    @State private var hovering = false

    /// A command still running takes the live accent rather than `exec`'s deliberate gray: the
    /// point of the neutral shell-activity color is that a finished command shouldn't compete with
    /// the data-flow rows, and a command that hasn't returned yet is the one thing on the feed that
    /// is still happening.
    private var accent: Color { event.isRunning ? palette.active : event.kind.accent(palette) }
    private var hasPayload: Bool { event.preview != nil }

    /// Built once for the row and shared with its inspector, so the flow line and the chain diagram
    /// can never disagree about what sits either side of the link.
    private var pipeFlow: NashPipeFlow? {
        guard case .pipe(let pipelineID, let fromIndex, let toIndex, let fromText, let toText) =
            event.details
        else { return nil }
        return NashPipeFlow(
            pipelineID: pipelineID, fromIndex: fromIndex, toIndex: toIndex, fromText: fromText,
            toText: toText, stages: pipeStages)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 2) {
            line
            // Only the data-flow kinds get a second line, and only to show what actually crossed.
            if let teaser = event.previewTeaser, !isExpanded, event.kind == .redirect || event.kind == .pipe {
                Text(teaser)
                    .font(.caption2.monospaced())
                    .foregroundStyle(.tertiary)
                    .lineLimit(1)
                    .truncationMode(.tail)
                    .padding(.leading, Self.gutter)
            }
            if isExpanded { expansion }
        }
        .padding(.vertical, 3)
        .padding(.horizontal, 6)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(hovering ? AppTheme.surface.opacity(0.6) : .clear, in: .rect(cornerRadius: 5))
        .contentShape(.rect)
        .onTapGesture { toggle() }
        .onHover { hovering = $0 }
        .contextMenu {
            Button("Copy Summary") { Clipboard.copy(event.summary) }
            if let preview = event.preview {
                Button("Copy Payload") { Clipboard.copy(preview) }
            }
        }
    }

    /// Width of the time + glyph gutter, so wrapped/second lines align under the content.
    private static let gutter: CGFloat = 82

    // MARK: The line

    private var line: some View {
        HStack(alignment: .firstTextBaseline, spacing: 6) {
            Text(event.at, format: Self.clock)
                .font(.caption2.monospacedDigit())
                .foregroundStyle(.quaternary)
            // The operator as a shell user would write it — `$`, `>`, `>>`, `|`, `$()`. Denser than
            // a word and read faster by the audience this panel has.
            Text(glyph)
                .font(.caption2.monospaced().weight(.semibold))
                .foregroundStyle(accent)
                .frame(width: 24, alignment: .leading)
            if showsEnvironment {
                Image(systemName: event.environment.systemImage)
                    .font(.system(size: 9))
                    .foregroundStyle(.tertiary)
                    .help(event.environment.label)
            }
            content
            Spacer(minLength: 4)
            metric
            Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
                .font(.system(size: 8, weight: .semibold))
                .foregroundStyle(.tertiary)
                // Always reserved, revealed on hover: a chevron appearing on every row is noise,
                // but a row whose width shifts on hover is worse.
                .opacity(hovering || isExpanded ? 1 : 0)
        }
    }

    private static let clock = Date.FormatStyle.dateTime
        .hour(.twoDigits(amPM: .omitted)).minute().second()

    /// The shell's own notation for what this event was.
    private var glyph: String {
        switch event.details {
        case .command: "$"
        case .directory: "cd"
        case .environment: "="
        // The operator as written, fd and all (`2>`), which is the most precise three characters
        // available for a redirect.
        case .redirect(let op, let fd, _): fd == 1 || fd == 0 ? op : "\(fd)\(op.drop(while: \.isNumber))"
        case .substitution: "$()"
        case .pipe: "|"
        case .fallback: "!"
        case .dropped: "✕"
        case .policy: "⊘"
        }
    }

    @ViewBuilder
    private var content: some View {
        switch event.details {
        case .command(let argv, let cwd, let exitCode, _, _):
            HStack(alignment: .firstTextBaseline, spacing: 5) {
                Text(argv.first ?? "")
                    .font(.caption.monospaced().weight(.semibold))
                    .foregroundStyle(exitCode == 0 ? AnyShapeStyle(.primary) : AnyShapeStyle(palette.danger))
                    .layoutPriority(1)
                if argv.count > 1 {
                    Text(argv.dropFirst().joined(separator: " "))
                        .font(.caption.monospaced())
                        .foregroundStyle(.secondary)
                        .lineLimit(1)
                        .truncationMode(.middle)
                }
                if exitCode != 0 {
                    Text("exit \(exitCode)")
                        .font(.caption2.monospacedDigit().weight(.medium))
                        .foregroundStyle(palette.danger)
                        .layoutPriority(1)
                }
                // Only when it isn't the worktree the panel is already scoped to.
                if let elsewhere = cwdIfNotable(cwd) {
                    Text("in \(elsewhere)")
                        .font(.caption2)
                        .foregroundStyle(.tertiary)
                        .lineLimit(1)
                        .truncationMode(.head)
                        .layoutPriority(1)
                }
            }
        case .directory(_, let to):
            Text(abbreviate(to)).font(.caption.monospaced())
        case .environment(_, let names):
            environmentNames(names)
        case .redirect(let op, let fd, let target):
            let flow = NashRedirectFlow(op: op, fd: fd, target: target)
            // The full path, not the file name — a second line repeating it was the redundancy.
            NashFlowLine(
                source: flow.direction == .input ? (flow.path ?? flow.source) : flow.source,
                destination: flow.direction == .input ? flow.destination : (flow.path ?? flow.destination),
                tint: accent)
        case .substitution:
            // The substituted value *is* the event; showing it inline saves the row a second line.
            HStack(spacing: 5) {
                Text(event.previewTeaser ?? "(no value)")
                    .font(.caption.monospaced())
                    .foregroundStyle(event.previewTeaser == nil ? .tertiary : .secondary)
                    .lineLimit(1)
                    .truncationMode(.middle)
            }
        case .pipe:
            if let pipeFlow {
                NashFlowLine(source: pipeFlow.producer, destination: pipeFlow.consumer, tint: accent)
            }
        case .fallback(let reason, _):
            Text("re-execed bash — \(reason)")
                .font(.caption).foregroundStyle(palette.attention).lineLimit(1)
        case .dropped(let count):
            Text("\(count) event\(count == 1 ? "" : "s") dropped under back-pressure")
                .font(.caption).foregroundStyle(palette.danger).lineLimit(1)
        case .policy(let lever, let value, let action, _):
            Text(
                action == "honored"
                    ? "operator lever \(lever)=\(value)"
                    : "refused \(lever)=\(value) from the environment")
                .font(.caption).foregroundStyle(palette.danger).lineLimit(1)
        }
    }

    @ViewBuilder
    private var metric: some View {
        if event.isRunning {
            // The one row whose trailing metric isn't a number yet: how long it has been running,
            // counting up on its own, beside a spinner. `.timer` is a live count from the row's own
            // clock, so no ticking state has to be threaded through a list that may hold hundreds
            // of rows — and the elapsed time is what says whether a spinner is progress or a hang.
            HStack(spacing: 5) {
                Text(event.at, style: .timer)
                    .font(.caption2.monospacedDigit())
                    .foregroundStyle(.tertiary)
                    .lineLimit(1)
                    .fixedSize()
                ProgressView()
                    .progressViewStyle(.circular)
                    .controlSize(.mini)
                    .scaleEffect(0.6)
                    .frame(width: 10, height: 10)
            }
            .help("Still running — nash hasn't reported an exit code for this command yet")
        } else if let bytes = event.bytes {
            Text(NashEventLog.formatBytes(bytes))
                .font(.caption2.monospacedDigit().weight(.medium))
                .foregroundStyle(bytes == 0 ? AnyShapeStyle(.quaternary) : AnyShapeStyle(accent))
                .help(event.truncated ? "\(bytes) bytes crossed; nash captured only the head" : "\(bytes) bytes crossed")
        } else if case .command(_, _, _, .some(let ms), _) = event.details {
            Text(NashEventLog.formatDuration(ms))
                .font(.caption2.monospacedDigit())
                .foregroundStyle(.quaternary)
        }
    }

    private func environmentNames(_ names: [String]) -> some View {
        FlowLayout(spacing: 4) {
            ForEach(names, id: \.self) { name in
                let sensitive = looksSensitive(name)
                Text(name)
                    .font(.caption2.monospaced())
                    .foregroundStyle(sensitive ? palette.attention : .secondary)
                    .padding(.horizontal, 4)
                    .padding(.vertical, 1)
                    .background(
                        (sensitive ? palette.attention : Color.secondary).opacity(0.12),
                        in: .rect(cornerRadius: 3))
                    .help(sensitive
                        ? "Credential-shaped name. nash reports names only, never values."
                        : "nash reports names only, never values.")
            }
        }
    }

    // MARK: Expansion

    /// Everything the line left out. Every row expands, not just the ones carrying bytes — that is
    /// what lets the line drop the cwd/exit/path clutter without losing it.
    private var expansion: some View {
        VStack(alignment: .leading, spacing: 5) {
            NashFactList(facts: facts)
            if let preview = event.preview {
                NashPayloadInspector(
                    event: event, preview: preview, pipeFlow: pipeFlow, tint: accent)
            }
        }
        .padding(.leading, Self.gutter)
        .padding(.top, 2)
    }

    private var facts: [(String, String)] {
        switch event.details {
        case .command(_, let cwd, let exitCode, let durationMs, let pipeline):
            // Not the argv: the line above already shows it in full. Only what the line left out.
            var facts = [("in", cwd)]
            // Said in words, because the line says it with a spinner: an expanded row that showed
            // a cwd and nothing else would read as a command that finished and reported nothing.
            if event.isRunning { facts.append(("state", "running — no exit code yet")) }
            if exitCode != 0 { facts.append(("exit", "\(exitCode)")) }
            if let durationMs { facts.append(("took", NashEventLog.formatDuration(durationMs))) }
            if let pipeline {
                facts.append(("stage", "\(pipeline.index + 1) of \(pipeline.length)"))
            }
            return facts
        case .directory(let from, let to):
            return [("from", from), ("to", to)]
        case .environment(let op, let names):
            return [("operator", op), ("names", names.joined(separator: " "))]
        case .redirect(let op, let fd, let target):
            let flow = NashRedirectFlow(op: op, fd: fd, target: target)
            return [
                ("operator", op), ("stream", flow.direction == .input ? flow.destination : flow.source),
                ("action", flow.verb), ("target", target ?? "inline (no path)"),
            ] + byteFacts
        case .substitution:
            return byteFacts
        case .pipe(let pipelineID, let fromIndex, let toIndex, _, _):
            return [("pipeline", "\(pipelineID)"), ("link", "\(fromIndex + 1) → \(toIndex + 1)")]
                + byteFacts
        case .fallback(let reason, let input):
            return [("reason", reason)] + (input.isEmpty ? [] : [("input", input)])
        case .dropped(let count):
            return [("dropped", "\(count)")]
        case .policy(let lever, let value, let action, let note):
            return [("lever", lever), ("value", value), ("action", action)]
                + (note.isEmpty ? [] : [("note", note)])
        }
    }

    private var byteFacts: [(String, String)] {
        var facts: [(String, String)] = []
        if let bytes = event.bytes {
            facts.append(("bytes", "\(bytes)\(event.truncated ? " (preview capped)" : "")"))
        }
        if let hash = event.hash, !hash.isEmpty { facts.append(("hash", hash)) }
        return facts
    }

    // MARK: Paths

    /// The cwd, but only when it is worth a reader's attention: somewhere other than the worktree
    /// this whole panel is scoped to.
    private func cwdIfNotable(_ cwd: String) -> String? {
        guard let worktreeRoot, !worktreeRoot.isEmpty else { return abbreviate(cwd) }
        guard cwd != worktreeRoot else { return nil }
        // Inside the worktree: the relative part is the only new information. `./` keeps it from
        // reading as a bare word ("in sub" vs "in ./sub").
        if cwd.hasPrefix(worktreeRoot + "/") { return "./" + cwd.dropFirst(worktreeRoot.count + 1) }
        return abbreviate(cwd)
    }

    /// Home-relative and shortened from the front — the tail of a path identifies it.
    private func abbreviate(_ path: String) -> String {
        let home = FileManager.default.homeDirectoryForCurrentUser.path
        // Only at a path boundary — otherwise `/Users/ada2/proj` would render as `~2/proj`.
        let underHome = path == home || path.hasPrefix(home + "/")
        var shortened = underHome ? "~" + path.dropFirst(home.count) : path
        let parts = shortened.split(separator: "/")
        if parts.count > 3 { shortened = "…/" + parts.suffix(2).joined(separator: "/") }
        return shortened
    }
}

/// The label/value list an expanded row shows — the facts the one-line layout leaves off.
private struct NashFactList: View {
    let facts: [(String, String)]

    var body: some View {
        VStack(alignment: .leading, spacing: 1) {
            ForEach(facts, id: \.0) { label, value in
                HStack(alignment: .firstTextBaseline, spacing: 6) {
                    Text(label)
                        .font(.caption2)
                        .foregroundStyle(.quaternary)
                        .frame(width: 52, alignment: .trailing)
                    Text(value)
                        .font(.caption2.monospaced())
                        .foregroundStyle(.secondary)
                        .textSelection(.enabled)
                        .fixedSize(horizontal: false, vertical: true)
                    Spacer(minLength: 0)
                }
            }
        }
    }
}

// MARK: - Flow line

/// `source ──▸ destination` — the shape a redirect and a pipe link share, so the two read as one
/// idea (bytes moving between two named things) instead of two unrelated row formats.
struct NashFlowLine: View {
    let source: String
    let destination: String
    let tint: Color

    var body: some View {
        HStack(spacing: 5) {
            end(source)
            Image(systemName: "arrow.right")
                .font(.system(size: 8, weight: .bold))
                .foregroundStyle(tint)
            end(destination)
            Spacer(minLength: 0)
        }
    }

    /// Middle truncation: both ends of a command matter (`curl … | sh` — the verb and the target),
    /// and the sidebar is narrow enough that something has to give.
    private func end(_ text: String) -> some View {
        Text(text)
            .font(.caption.monospaced())
            .lineLimit(1)
            .truncationMode(.middle)
    }
}

// MARK: - Payload inspector

/// The bytes nash intercepted, revealed in place: what crossed, how much of it we have, and the
/// content itself with line numbers so you can talk about "line 12".
private struct NashPayloadInspector: View {
    let event: NashEvent
    let preview: String
    /// Non-nil for a `pipe` row; drives the chain diagram.
    let pipeFlow: NashPipeFlow?
    let tint: Color

    /// Lines rendered before the row offers to show the rest. An expanded row's content is built
    /// eagerly (only the *rows* are lazy), so a 64 KB payload must not become 2000 `Text`s the
    /// moment someone clicks a chevron.
    private static let collapsedLineLimit = 24
    private static let hardLineLimit = 2000
    /// Stages the chain diagram will draw. Well past any real pipeline; a backstop, not a policy.
    private static let maxChainRows = 64

    @State private var showingAllLines = false
    @State private var wraps = true

    private var lines: [Substring] { preview.split(separator: "\n", omittingEmptySubsequences: false) }

    private var visibleLines: ArraySlice<Substring> {
        let limit = showingAllLines ? Self.hardLineLimit : Self.collapsedLineLimit
        return lines.prefix(limit)
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            // Only worth drawing when there is more than this link to show — for `a | b` the chain
            // would just restate the row's own line.
            if let pipeFlow, (pipeFlow.length ?? 0) > 2 { chain(pipeFlow) }
            header
            content
            footer
        }
        .padding(7)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(AppTheme.sidebar, in: .rect(cornerRadius: 5))
        .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(AppTheme.hairline, lineWidth: 1))
    }

    /// The whole pipeline, with this row's link marked — so a link opened in isolation still says
    /// what it sits between.
    private func chain(_ flow: NashPipeFlow) -> some View {
        // Every input here is wire data bounding a view-building loop in a *non-lazy* stack, so it
        // is clamped twice: `parseShellBatch` caps indices and stage counts at
        // `maxPipelineStages`, and this takes the smallest of what was reported anyway.
        let reported = max((flow.stages.keys.max() ?? 0) + 1, flow.toIndex + 1)
        let count = max(min(flow.length ?? reported, reported, Self.maxChainRows), 0)
        let fromIndex = flow.fromIndex
        return VStack(alignment: .leading, spacing: 2) {
            ForEach(0..<max(count, 0), id: \.self) { index in
                    HStack(spacing: 5) {
                        Text("\(index + 1)")
                            .font(.caption2.monospacedDigit())
                            .foregroundStyle(.tertiary)
                            .frame(width: 12, alignment: .trailing)
                        let named = flow.isNamed(at: index)
                        Text(flow.name(at: index))
                            .font(.caption2.monospaced())
                            .foregroundStyle(named ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary))
                            .lineLimit(1)
                            .truncationMode(.middle)
                        if let stage = flow.stages[index], stage.exitCode != 0 {
                            Text("exit \(stage.exitCode)")
                                .font(.caption2.monospacedDigit())
                                .foregroundStyle(.tertiary)
                        }
                        Spacer(minLength: 0)
                    }
                    // The gap after the producing stage is this link.
                    if index == fromIndex {
                        HStack(spacing: 5) {
                            Image(systemName: "arrow.down")
                                .font(.system(size: 8, weight: .bold))
                                .frame(width: 12)
                            Text(event.bytes.map(NashEventLog.formatBytes) ?? "")
                                .font(.caption2.monospacedDigit().weight(.medium))
                            Text("shown below")
                                .font(.caption2)
                                .foregroundStyle(.tertiary)
                        }
                        .foregroundStyle(tint)
                    } else if index < count - 1 {
                        Image(systemName: "arrow.down")
                            .font(.system(size: 8))
                            .foregroundStyle(.quaternary)
                            .frame(width: 12)
                    }
            }
            Divider().padding(.vertical, 2)
        }
    }

    private var header: some View {
        HStack(spacing: 6) {
            Text(headline)
                .font(.caption2.weight(.medium))
                .foregroundStyle(.secondary)
            Spacer(minLength: 0)
            if let hash = event.hash, !hash.isEmpty {
                Text(String(hash.prefix(8)))
                    .font(.caption2.monospaced())
                    .foregroundStyle(.tertiary)
                    .help("nash's FNV-1a hash of the captured bytes — rows with the same hash carried the same data")
            }
            // A word, not a glyph: no icon reads unambiguously as "wrap long lines". Built like
            // `CopyButton` beside it — `.plain`, with the sizing and styling on the label rather
            // than the Button — which is the shape that rasterizes predictably here.
            Button { wraps.toggle() } label: {
                Text(wraps ? "wrap" : "no wrap")
                    .font(.caption2)
                    .foregroundStyle(.tertiary)
                    .frame(height: 14)
                    .contentShape(.rect)
            }
            .buttonStyle(.plain)
            .help(wraps ? "Stop wrapping long lines" : "Wrap long lines")
            CopyButton(text: preview, help: "Copy the intercepted bytes", size: 9)
        }
    }

    private var headline: String {
        switch event.details {
        case .pipe: "Data that crossed this pipe"
        // nash prefixes the fd onto the operator for stderr (`2>`), so test the operator alone.
        case .redirect(let op, _, _): op.drop(while: \.isNumber).hasPrefix("<") ? "Data read in" : "Data written out"
        case .substitution: "Substituted value"
        default: "Captured data"
        }
    }

    @ViewBuilder
    private var content: some View {
        let body = VStack(alignment: .leading, spacing: 0) {
            ForEach(Array(visibleLines.enumerated()), id: \.offset) { index, line in
                HStack(alignment: .top, spacing: 6) {
                    Text("\(index + 1)")
                        .font(.caption2.monospacedDigit())
                        .foregroundStyle(.quaternary)
                        .frame(width: 20, alignment: .trailing)
                    Text(line.isEmpty ? " " : String(line))
                        .font(.caption2.monospaced())
                        .foregroundStyle(.secondary)
                        .lineLimit(wraps ? nil : 1)
                        .fixedSize(horizontal: !wraps, vertical: false)
                    Spacer(minLength: 0)
                }
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)

        if wraps {
            body
        } else {
            ScrollView(.horizontal, showsIndicators: false) { body }
        }
    }

    @ViewBuilder
    private var footer: some View {
        let hidden = lines.count - visibleLines.count
        // What one more click would reveal, vs. what the hard cap keeps back regardless. Conflating
        // the two left a payload over `hardLineLimit` lines showing a dead "Show 0 more lines".
        let expandable = showingAllLines ? 0 : min(hidden, Self.hardLineLimit - visibleLines.count)
        if expandable > 0 {
            Button {
                showingAllLines = true
            } label: {
                Text("Show \(expandable) more line\(expandable == 1 ? "" : "s")")
                    .font(.caption2)
            }
            .buttonStyle(.borderless)
            .foregroundStyle(tint)
        }
        if hidden > expandable {
            Text("\(hidden - expandable) further line\(hidden - expandable == 1 ? "" : "s") not rendered — copy to get the whole payload.")
                .font(.caption2)
                .foregroundStyle(.tertiary)
                .fixedSize(horizontal: false, vertical: true)
        }
        if event.truncated {
            Text("nash captured only the first \(NashEventLog.formatBytes(preview.utf8.count)) of \(event.bytes.map(NashEventLog.formatBytes) ?? "this payload") — previews are capped at 64 KB (docs/NASH.md §5.4).")
                .font(.caption2)
                .foregroundStyle(.tertiary)
                .fixedSize(horizontal: false, vertical: true)
        }
    }
}

// MARK: - Layout

/// Wraps its subviews onto as many lines as they need, each at its intrinsic size. For runs of
/// short, variable-length elements where the alternative — an `HStack` compressing them to fit —
/// hides or mangles content: the environment-variable names here (truncating could hide the one
/// credential-shaped name worth noticing), and the Carbon and Covalence Mesh viewers' summary
/// chip strips (an over-full `HStack` hyphenates their labels mid-word).
struct FlowLayout: Layout {
    var spacing: CGFloat = 4

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        let width = proposal.width ?? .infinity
        var x: CGFloat = 0
        var rowHeight: CGFloat = 0
        var total: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > 0, x + size.width > width {
                total += rowHeight + spacing
                x = 0
                rowHeight = 0
            }
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
        // The width actually used, never the whole proposal — an `.infinity` proposal would
        // otherwise be reported back as an infinite ideal size.
        return CGSize(width: min(proposal.width ?? x, x), height: total + rowHeight)
    }

    func placeSubviews(
        in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()
    ) {
        var x = bounds.minX
        var y = bounds.minY
        var rowHeight: CGFloat = 0
        for subview in subviews {
            let size = subview.sizeThatFits(.unspecified)
            if x > bounds.minX, x + size.width > bounds.maxX {
                x = bounds.minX
                y += rowHeight + spacing
                rowHeight = 0
            }
            subview.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
            x += size.width + spacing
            rowHeight = max(rowHeight, size.height)
        }
    }
}

extension NashEnvironment {
    var systemImage: String {
        switch kind {
        case .agent: "sparkles"
        case .host: "desktopcomputer"
        case .macVM: "macwindow"
        case .linuxVM: "display"
        case .container: "shippingbox"
        case .app: "hammer"
        }
    }
}
