import SwiftUI
import NucleicCore

/// Everything the nash agent shell emitted for the open chat, live (docs/NASH.md §5).
///
/// nash is the agent's own shell, so its gates see every command it runs *and* the data crossing
/// every redirect, pipe, and `$(…)`. Only the exec events survive into the Control panel's command
/// feed; the rest are reduced to a log line. This panel is the unreduced view.
///
/// It is built around the interception, not the log: the three kinds that carry bytes render as a
/// `source ──▸ destination` flow with the captured payload one click away, a pipe link names the
/// commands either side of it rather than bare stage indices, and the kinds that merely narrate
/// shell state stay gray behind them. Two dropdowns (emitter, kinds) and a search narrow the
/// stream.
///
/// A command that hasn't returned yet is a row too — nash announces anything still running after a
/// moment (docs/NASH.md §5.1), so a long build appears while it is still building, spinning, with
/// its elapsed time counting up, and becomes its own finished row in place when it exits. Without
/// that the panel went quiet during exactly the commands worth watching, and the row for a
/// five-minute build showed up only once the build was over.
///
/// Reads straight off `AppStore.nashEvents`, so it fills in as the agent, its VMs, host
/// commands, and managed containers work — and, because that log is durable (docs/NASH.md §9.4),
/// opening a chat from a previous run shows the history it recorded then rather than an empty
/// panel. "Clear Retained Rows" is what drops it, on disk as well as on screen.
///
/// ``pinnedEnvironment`` narrows the whole panel to one emitter, which is how the VM Monitor shows
/// a guest's shell under that guest's screen.
struct NashViewerPanel: View {
    let session: Session?
    /// Scope the whole panel to one emitter, for the copies embedded in another panel that are
    /// *about* a single environment — the VM Monitor's, which sits under that guest's screen and so
    /// may only ever show that guest's shell. A pinned panel filters the stream at the source, so
    /// its kind counts, search, and empty state all describe the pinned environment alone, and the
    /// emitter picker becomes a label naming it (there is nothing left for it to choose between).
    var pinnedEnvironment: NashEnvironment? = nil
    @Environment(AppStore.self) private var store
    @Environment(\.appPalette) private var palette

    /// Hidden rather than shown, so the default (empty) state is "everything" and a newly added
    /// kind is visible without anyone opting in.
    @State private var hiddenKinds: Set<NashEvent.Kind> = []
    @State private var environmentFilter: NashEnvironment?
    @State private var query = ""
    /// Auto-scroll to the newest row. Pausing lets you read while the agent keeps working.
    @State private var follow = true
    @State private var expanded: Set<UInt64> = []

    private var allEvents: [NashEvent] {
        guard let session else { return [] }
        let events = store.nashEvents.events(for: session.id)
        guard let pinnedEnvironment else { return events }
        return events.filter { $0.environment == pinnedEnvironment }
    }

    private var environments: [NashEnvironment] {
        guard let session else { return [] }
        if let pinnedEnvironment { return [pinnedEnvironment] }
        var available = Set(store.nashEvents.environments(for: session.id))
        // Keep the session's primary environment available before its first emission and after a
        // clear. Also retain the current choice if clearing removed its last retained event.
        available.insert(.agent)
        if let environmentFilter { available.insert(environmentFilter) }
        return available.sorted { $0.sortKey < $1.sortKey }
    }

    /// The per-menu counts, tallied in one pass.
    private struct Tallies {
        var byKind: [NashEvent.Kind: Int] = [:]
        var byEnvironment: [NashEnvironment: Int] = [:]
    }

    private func tally(_ events: [NashEvent]) -> Tallies {
        var tallies = Tallies()
        for event in events {
            tallies.byKind[event.kind, default: 0] += 1
            tallies.byEnvironment[event.environment, default: 0] += 1
        }
        return tallies
    }

    /// Newest first — the log reads top-down, so the emission you just triggered is where your eyes
    /// already are.
    ///
    /// Searching matches payloads, which are up to 64 KB each, so this is expensive enough that it
    /// must run **once per render**: `body` computes it and hands it down. As a computed property
    /// read by both the list and its follow-scroll, it was re-scanning every retained payload
    /// several times per keystroke.
    private func filtered(_ events: [NashEvent]) -> [NashEvent] {
        let needle = query.trimmingCharacters(in: .whitespaces)
        return events.reversed().filter { event in
            guard environmentFilter == nil || event.environment == environmentFilter else { return false }
            guard !hiddenKinds.contains(event.kind) else { return false }
            guard !needle.isEmpty else { return true }
            return event.matches(needle, pipeStages: stages(for: event))
        }
    }

    /// The commands nash saw as each stage of this row's pipeline. Empty for every kind but `pipe`.
    private func stages(for event: NashEvent) -> [Int: NashPipelineStage] {
        guard event.kind == .pipe, let session, let key = event.pipelineKey else { return [:] }
        return store.nashEvents.stages(for: session.id, pipeline: key)
    }

    var body: some View {
        Group {
            if session == nil {
                placeholder(
                    title: "No chat open",
                    message: "Open a chat to watch what its shell does.")
            } else {
                // Every pass over the ring happens here, once, and is handed down.
                let retained = allEvents
                let visible = filtered(retained)
                VStack(spacing: 0) {
                    controls(tally(retained), total: retained.count, visible: visible)
                    Divider()
                    list(visible, retainedCount: retained.count)
                }
            }
        }
        .onChange(of: session?.id) { clearFilters() }
    }

    private func clearFilters() {
        environmentFilter = nil
        hiddenKinds.removeAll()
        query = ""
        expanded.removeAll()
    }

    // MARK: - Controls

    /// One row, not two: the search field flexes into whatever the menus leave, which is the same
    /// space a second row would have cost the list.
    private func controls(_ tallies: Tallies, total: Int, visible: [NashEvent]) -> some View {
        HStack(spacing: 6) {
            emitterMenu(tallies, total: total)
            kindMenu(tallies, total: total)
            HStack(spacing: 4) {
                Image(systemName: "magnifyingglass").font(.system(size: 9)).foregroundStyle(.tertiary)
                TextField("Search", text: $query)
                    .textFieldStyle(.plain)
                    .font(.caption)
                if !query.isEmpty {
                    Button { query = "" } label: {
                        Image(systemName: "xmark.circle.fill").font(.system(size: 9))
                    }
                    .buttonStyle(.borderless)
                    .foregroundStyle(.tertiary)
                }
            }
            .padding(.horizontal, 5)
            .padding(.vertical, 2)
            .background(AppTheme.surface, in: .rect(cornerRadius: 5))
            .overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(AppTheme.hairline, lineWidth: 1))
            Button { follow.toggle() } label: {
                Image(systemName: follow ? "pause.circle" : "play.circle").font(.system(size: 11))
            }
            .buttonStyle(.borderless)
            .foregroundStyle(follow ? AnyShapeStyle(.secondary) : AnyShapeStyle(palette.accent))
            .help(follow ? "Pause auto-scroll" : "Follow new emissions")
            overflowMenu(visible)
        }
        .padding(.horizontal, 8)
        .padding(.vertical, 5)
    }

    /// Which environment's nash emitted the row. Only offered when the session has more than one —
    /// a lone "Agent" menu is a control that can't change anything. A pinned panel spends the same
    /// slot on a plain label, so the scoped stream still says whose shell it is showing.
    @ViewBuilder
    private func emitterMenu(_ tallies: Tallies, total: Int) -> some View {
        if let pinnedEnvironment {
            menuLabel(pinnedEnvironment.label, systemImage: pinnedEnvironment.systemImage)
                .fixedSize()
                .help("Showing only what \(pinnedEnvironment.label)'s shell emitted")
        } else if environments.count > 1 {
            let counts = tallies.byEnvironment
            Menu {
                menuChoice(
                    "All environments", count: total, selected: environmentFilter == nil
                ) { environmentFilter = nil }
                Divider()
                ForEach(environments, id: \.self) { environment in
                    menuChoice(
                        environment.label, systemImage: environment.systemImage,
                        count: counts[environment] ?? 0, selected: environmentFilter == environment
                    ) { environmentFilter = environment }
                }
            } label: {
                menuLabel(
                    environmentFilter?.label ?? "All",
                    systemImage: environmentFilter?.systemImage ?? "square.stack.3d.up")
            }
            .menuStyle(.borderlessButton)
            .fixedSize()
            .help("Filter by the environment that emitted the event")
        }
    }

    /// Multi-select, grouped the way an operator thinks about the stream: the data nash intercepted,
    /// the shell state it watched change, and problems with the observation itself.
    private func kindMenu(_ tallies: Tallies, total: Int) -> some View {
        let counts = tallies.byKind
        return Menu {
            menuChoice("All kinds", count: total, selected: hiddenKinds.isEmpty) {
                hiddenKinds.removeAll()
            }
            ForEach(NashEvent.Kind.Group.allCases, id: \.self) { group in
                let kinds = NashEvent.Kind.allCases.filter { $0.group == group }
                Section(group.label) {
                    Button("Only \(group.label.lowercased())") {
                        hiddenKinds = Set(NashEvent.Kind.allCases).subtracting(kinds)
                    }
                    ForEach(kinds, id: \.self) { kind in
                        Toggle(isOn: visibility(of: kind)) {
                            Text("\(kind.label)  (\(counts[kind] ?? 0))")
                        }
                    }
                }
            }
        } label: {
            menuLabel(kindMenuTitle, systemImage: "line.3.horizontal.decrease.circle")
        }
        .menuStyle(.borderlessButton)
        .fixedSize()
        .help("Choose which kinds of emission to show")
    }

    private var kindMenuTitle: String {
        let shown = NashEvent.Kind.allCases.filter { !hiddenKinds.contains($0) }
        switch shown.count {
        case NashEvent.Kind.allCases.count: return "All kinds"
        case 1: return shown[0].label
        default: return "\(shown.count) kinds"
        }
    }

    private func overflowMenu(_ visible: [NashEvent]) -> some View {
        Menu {
            Button("Expand All Payloads") {
                expanded = Set(visible.filter { $0.preview != nil }.map(\.id))
            }
            Button("Collapse All") { expanded.removeAll() }
            Button("Copy Visible Rows") { Clipboard.copy(transcript(visible)) }
            Divider()
            // Retention is per session, not per environment, so a pinned panel says so rather than
            // implying the button drops only the rows it is showing.
            Button(
                pinnedEnvironment == nil ? "Clear Retained Rows" : "Clear All Retained Rows",
                role: .destructive
            ) {
                if let id = session?.id { store.nashEvents.clear(sessionID: id) }
                expanded.removeAll()
            }
        } label: {
            Image(systemName: "ellipsis.circle").font(.system(size: 11))
        }
        .menuStyle(.borderlessButton)
        .menuIndicator(.hidden)
        .fixedSize()
    }

    /// A flat, pasteable rendering of exactly what's on screen.
    private func transcript(_ visible: [NashEvent]) -> String {
        visible.map { event in
            let time = event.at.formatted(date: .omitted, time: .standard)
            let badge = event.kind.badge.padding(toLength: 6, withPad: " ", startingAt: 0)
            // The spinner has no text form, and a pasted row that silently dropped "this hadn't
            // finished" would read as a command that completed successfully.
            return "\(time)  \(badge)  \(event.summary)\(event.isRunning ? "  (running)" : "")"
        }
        .joined(separator: "\n")
    }

    private func visibility(of kind: NashEvent.Kind) -> Binding<Bool> {
        Binding(
            get: { !hiddenKinds.contains(kind) },
            set: { visible in
                if visible { hiddenKinds.remove(kind) } else { hiddenKinds.insert(kind) }
            })
    }

    /// A menu row that reads as a choice: checkmark column, label, and how many rows it would show.
    private func menuChoice(
        _ title: String, systemImage: String? = nil, count: Int, selected: Bool,
        action: @escaping () -> Void
    ) -> some View {
        Button(action: action) {
            HStack {
                Image(systemName: "checkmark").opacity(selected ? 1 : 0)
                if let systemImage { Image(systemName: systemImage) }
                Text(title)
                Spacer()
                Text("\(count)")
            }
        }
    }

    private func menuLabel(_ title: String, systemImage: String) -> some View {
        HStack(spacing: 3) {
            Image(systemName: systemImage).font(.system(size: 9))
            Text(title).font(.caption).lineLimit(1)
        }
        .foregroundStyle(.secondary)
    }

    // MARK: - List

    private func list(_ visible: [NashEvent], retainedCount: Int) -> some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 1) {
                    if visible.isEmpty {
                        emptyState(retainedCount: retainedCount)
                    }
                    ForEach(visible) { event in
                        NashEventRow(
                            event: event,
                            pipeStages: stages(for: event),
                            worktreeRoot: session?.worktreePath,
                            showsEnvironment: environments.count > 1 && environmentFilter == nil,
                            isExpanded: expanded.contains(event.id),
                            toggle: { toggle(event) })
                        .id(event.id)
                    }
                }
                .padding(.horizontal, 6)
                .padding(.vertical, 6)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
            .onChange(of: visible.first?.id) { _, newest in
                guard follow, let newest else { return }
                proxy.scrollTo(newest, anchor: .top)
            }
        }
    }

    @ViewBuilder
    private func emptyState(retainedCount: Int) -> some View {
        if retainedCount == 0 {
            placeholder(
                title: "Nothing from nash yet",
                message: pinnedEnvironment.map { environment in
                    """
                    nash is the shell inside \(environment.label). Every command run there shows up \
                    here — along with the data crossing every pipe, redirect, and $(…), which \
                    nothing else in the app sees.
                    """
                }
                    ?? """
                    nash is the agent's shell. Every command it runs shows up here — along with the \
                    data crossing every pipe, redirect, and $(…), which nothing else in the app sees.
                    """)
        } else {
            VStack(spacing: 8) {
                Image(systemName: "line.3.horizontal.decrease.circle")
                    .font(.largeTitle).foregroundStyle(.tertiary)
                Text("Nothing matches this filter")
                    .font(.callout).foregroundStyle(.secondary)
                Text("\(retainedCount) row\(retainedCount == 1 ? "" : "s") hidden")
                    .font(.caption).foregroundStyle(.tertiary)
                Button("Clear Filters") { clearFilters() }
                    .buttonStyle(.link)
                    .font(.caption)
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 40)
        }
    }

    private func toggle(_ event: NashEvent) {
        if expanded.contains(event.id) { expanded.remove(event.id) } else { expanded.insert(event.id) }
    }

    private func placeholder(title: String, message: String) -> some View {
        VStack(spacing: 8) {
            Image(systemName: "waveform").font(.largeTitle).foregroundStyle(.tertiary)
            Text(title).font(.callout).foregroundStyle(.secondary)
            Text(message)
                .font(.caption)
                .foregroundStyle(.tertiary)
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: 260)
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .padding(.vertical, 40)
    }
}
