import SwiftUI
import NucleicCore

// MARK: - Build/Run controls + console

extension BuildRunCommand.Kind {
    var title: String { self == .build ? "Build" : "Run" }
    var systemImage: String { self == .build ? "hammer" : "play.fill" }
}

/// Build / Run buttons. Detects candidate commands from the manifests in the working
/// directory (Makefile, package.json, Package.swift, …) and runs the chosen one on the
/// host, streaming output into a console sheet. Renders nothing for a kind with no
/// detected commands; a single command is a plain button, several become a menu.
///
/// Used in two places: the project header (`compact == false`, bordered buttons over the
/// project root) and the open session's toolbar (`compact == true`, toolbar-styled buttons
/// over the session's worktree).
struct BuildRunControls: View {
    let project: Project
    /// Where to detect manifests and run commands. Defaults to the project root; a session
    /// passes its worktree path so build/run act on that chat's working copy.
    var workingDirectory: String? = nil
    /// Toolbar styling: drop the explicit `.bordered` chrome so the buttons adopt the
    /// surrounding toolbar's look.
    var compact = false

    @State private var commands: [BuildRunCommand] = []
    @State private var running: BuildRunCommand?

    private var cwd: String { workingDirectory ?? project.rootPath }

    var body: some View {
        Group {
            control(for: .build)
            control(for: .run)
        }
        .task(id: cwd) {
            let path = cwd
            commands = await Task.detached { BuildRunDetector.detect(in: path) }.value
        }
        .sheet(item: $running) { command in
            BuildRunConsoleView(command: command, cwd: cwd, origin: "buildrun:\(project.id)")
        }
    }

    @ViewBuilder
    private func control(for kind: BuildRunCommand.Kind) -> some View {
        let items = commands.filter { $0.kind == kind }
        if items.count == 1 {
            applyStyle(
                Button { running = items[0] } label: {
                    Label(kind.title, systemImage: kind.systemImage)
                }
                .help(items[0].label))
        } else if items.count > 1 {
            applyStyle(
                Menu {
                    ForEach(items) { command in
                        Button(command.label) { running = command }
                    }
                } label: {
                    Label(kind.title, systemImage: kind.systemImage)
                }
                .menuStyle(.button))
        }
    }

    /// In the header, give the control a bordered look and intrinsic width; in a toolbar,
    /// leave it bare so it matches the neighboring toolbar items.
    @ViewBuilder
    private func applyStyle(_ control: some View) -> some View {
        if compact {
            control
        } else {
            control.buttonStyle(.bordered).fixedSize()
        }
    }
}

/// Live console for a single build/run command: streams stdout/stderr as it arrives,
/// reports the exit status, and lets the user stop the process. Closing the sheet
/// terminates a still-running command.
struct BuildRunConsoleView: View {
    @Environment(\.dismiss) private var dismiss
    @Environment(\.appPalette) private var palette

    let command: BuildRunCommand
    let cwd: String
    /// Synthetic origin id for app-scoped shell observation (docs/NASH.md §7.7.3).
    let origin: String

    @State private var runner = BuildRunRunner()

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            header
            Divider()
            console
            Divider()
            footer
        }
        .frame(width: 660, height: 460)
        .task { runner.start(command: command.command, cwd: cwd, origin: origin) }
        .onDisappear { runner.stop() }
    }

    private var header: some View {
        HStack(spacing: 10) {
            Image(systemName: command.kind.systemImage)
                .foregroundStyle(.tint)
            VStack(alignment: .leading, spacing: 1) {
                Text(command.label).font(.headline)
                Text("\(command.source) · \(cwd)")
                    .font(.caption).foregroundStyle(.secondary)
                    .lineLimit(1).truncationMode(.middle)
            }
            Spacer()
            statusBadge
        }
        .padding(14)
    }

    @ViewBuilder
    private var statusBadge: some View {
        switch runner.phase {
        case .idle, .running:
            HStack(spacing: 6) {
                ProgressView().controlSize(.small)
                Text("Running").font(.caption).foregroundStyle(.secondary)
            }
        case .finished(let code):
            Label(code == 0 ? "Succeeded" : "Exited \(code)",
                  systemImage: code == 0 ? "checkmark.circle.fill" : "xmark.octagon.fill")
                .font(.caption.weight(.medium))
                .foregroundStyle(code == 0 ? palette.success : palette.danger)
        case .failed(let message):
            Label(message, systemImage: "exclamationmark.triangle.fill")
                .font(.caption.weight(.medium))
                .foregroundStyle(palette.danger)
                .lineLimit(1)
        }
    }

    private var console: some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(alignment: .leading, spacing: 0) {
                    ForEach(runner.lines) { line in
                        Text(line.text.isEmpty ? " " : line.text)
                            .font(.system(.caption, design: .monospaced))
                            .foregroundStyle(line.isError ? palette.danger : AppTheme.primaryText)
                            .textSelection(.enabled)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .id(line.id)
                    }
                    Color.clear.frame(height: 1).id(consoleBottomID)
                }
                .padding(12)
            }
            .background(DetailSurface(layer: .inner))
            .onChange(of: runner.lines.count) {
                withAnimation(.linear(duration: 0.1)) { proxy.scrollTo(consoleBottomID, anchor: .bottom) }
            }
        }
    }

    private var footer: some View {
        HStack {
            Text(command.command)
                .font(.system(.caption2, design: .monospaced))
                .foregroundStyle(.secondary)
                .lineLimit(1).truncationMode(.middle)
            Spacer()
            if runner.isRunning {
                Button("Stop") { runner.stop() }
            }
            Button("Close") { dismiss() }
                .keyboardShortcut(.defaultAction)
        }
        .padding(14)
    }

    private let consoleBottomID = "build-run-console-bottom"
}

/// Drives one host command, accumulating its output lines on the main actor for the
/// console view. Output is line-buffered by `ProcessHost`; we keep the last `maxLines`
/// so a runaway log can't grow without bound.
@MainActor
@Observable
final class BuildRunRunner {
    enum Phase: Equatable {
        case idle
        case running
        case finished(Int32)
        case failed(String)
    }

    struct ConsoleLine: Identifiable {
        let id: Int
        let text: String
        let isError: Bool
    }

    private(set) var lines: [ConsoleLine] = []
    private(set) var phase: Phase = .idle

    var isRunning: Bool { phase == .running || phase == .idle }

    private let host = ProcessHost()
    private var handle: (any ProcessHandle)?
    private var task: Task<Void, Never>?
    private var started = false
    private var nextID = 0
    private let maxLines = 5_000

    func start(command: String, cwd: String, origin: String) {
        guard !started else { return }
        started = true
        phase = .running
        task = Task { await run(command: command, cwd: cwd, origin: origin) }
    }

    /// Signal the running process to stop (SIGINT first; the process group winds down).
    func stop() {
        handle?.sendSignal(SIGINT)
    }

    private func run(command: String, cwd: String, origin: String) async {
        // Pin this host build/run's git (every remote) to the Managed Git key (never the user's
        // ssh-agent/personal keys). GIT_SSH_COMMAND governs only git, so a standalone `ssh` the
        // build runs is unaffected. No-op when no Managed Git credential is configured. Mirrors
        // host_exec (`runOnHost`).
        let (gitEnv, gitCleanup) = GitHubCredentialProvisioner.provisionHost()
        defer { gitCleanup() }
        var env = gitEnv
        env.removeValue(forKey: "GH_CONFIG_DIR")
        // Same shell pick as `runOnHost` (docs/NASH.md §7.7.1 — this site's comment above already
        // says it mirrors host_exec): bundled nash when the host shell setting says so, else
        // `/bin/zsh -lc`. Build/Run is app-scoped (no session approval server), so when nash runs
        // its observation reports to the shared `HostShellEventListener` under a per-spawn bearer
        // token and this build's synthetic origin id (§7.7.3). Registration failing (no sink, bind
        // failure) just means the build runs unobserved — never blocked (§5.5).
        let argv = CommandInterceptor.hostShellArgv(command)
        var observationToken: String?
        if argv[0] != "/bin/zsh",
            let observation = await HostShellEventListener.shared.register(origin: origin)
        {
            env["NUCLEIC_SHELL_HOOK_URL"] = observation.hookURL
            env["NUCLEIC_HOOK_TOKEN"] = observation.token
            env["NUCLEIC_SHELL_CAPTURE"] = HostShellSettings.shellCapture
            observationToken = observation.token
        }
        defer {
            // The spawn is over — retire its token so any late/forged post gets the 401.
            if let observationToken {
                Task { await HostShellEventListener.shared.unregister(token: observationToken) }
            }
        }
        let spec = ProcessSpec(
            executable: argv[0], args: Array(argv.dropFirst()),
            cwd: cwd, env: env, stdinMode: .closed)
        do {
            let handle = try await host.launch(spec)
            self.handle = handle
            // Drain and decode off the main actor, then apply bounded batches here
            // (docs/MAIN_THREAD_PERFORMANCE_PLAN.md item 8): a chatty build used to cost
            // one observable `lines` mutation — and one console re-render — per output
            // line. The buffer actor absorbs the line-rate; the flush loop commits at
            // ~12/s and drains whatever remains before the task group ends, so nothing
            // is lost at process exit.
            let buffer = ConsoleBuffer(drains: 2)
            await withTaskGroup(of: Void.self) { group in
                let out = handle.stdoutLines
                let err = handle.stderrLines
                group.addTask { await buffer.drain(out, isError: false) }
                group.addTask { await buffer.drain(err, isError: true) }
                group.addTask { [weak self] in await self?.flushLoop(buffer) }
            }
            let code = await handle.wait()
            phase = .finished(code)
        } catch {
            phase = .failed("Failed to launch: \(error.localizedDescription)")
        }
    }

    /// Pull batches from the buffer until every drain has finished and it runs dry.
    /// `take()` returning an empty batch means "still running, nothing new" — sleep out
    /// the cadence window; `nil` means done (and the final lines were already handed over).
    private func flushLoop(_ buffer: ConsoleBuffer) async {
        while let batch = await buffer.take() {
            if !batch.isEmpty { append(batch) }
            try? await Task.sleep(for: .milliseconds(80))
        }
    }

    private func append(_ batch: [ConsoleBuffer.Line]) {
        lines.append(contentsOf: batch.map { line in
            defer { nextID += 1 }
            return ConsoleLine(id: nextID, text: line.text, isError: line.isError)
        })
        if lines.count > maxLines { lines.removeFirst(lines.count - maxLines) }
    }
}

/// Off-main collector for a build's console output: both pipes drain on this actor's
/// executor (stdout/stderr identity preserved per line, arrival order kept), so the
/// main actor only ever sees the coalesced batches `flushLoop` pulls.
private actor ConsoleBuffer {
    struct Line: Sendable {
        let text: String
        let isError: Bool
    }

    private var pending: [Line] = []
    /// Drains still running; `take()` keeps the flush loop alive until all have ended.
    private var openDrains: Int

    init(drains: Int) { openDrains = drains }

    func drain(_ stream: AsyncThrowingStream<Data, Error>, isError: Bool) async {
        do {
            for try await data in stream {
                pending.append(Line(text: String(decoding: data, as: UTF8.self), isError: isError))
            }
        } catch {}
        openDrains -= 1
    }

    /// The buffered lines since the last call, emptying the buffer — or `nil` once every
    /// drain has ended AND nothing is pending (so the last batch is never dropped).
    func take() -> [Line]? {
        if pending.isEmpty && openDrains == 0 { return nil }
        let batch = pending
        pending = []
        return batch
    }
}
