import Foundation

/// The **app-scoped** `/shell-event` sink (docs/NASH.md §7.7.3): where host shells Nucleic spawns
/// *outside any session* report — Build/Run today, the Terminal pane later (§7.7.5). Session-scoped
/// spawns (`host_exec`, host monitors) use their run's own approval server and never come here.
///
/// A thin wrapper around a dedicated ``MCPApprovalServer`` bound to `127.0.0.1` on an ephemeral
/// port: the `/shell-event` route, its bearer-token gate, and `parseShellBatch` are exactly the
/// session pipeline's (tested) machinery — this type adds only lifecycle and attribution:
///
///   - **Lazy**: nothing binds until the first registered spawn; an app that never runs a
///     nash-shelled Build/Run opens no listener.
///   - **Per-spawn bearer token**, registered for the spawn's duration and unregistered after —
///     a late (or forged) post gets the route's 401. nash then just drops events (§5.4's `dropped`
///     doctrine): a spool fallback is deliberately omitted here, because an unreachable loopback
///     listener means the app itself is gone and nobody would ever drain it (§7.7.3).
///   - **Synthetic origin id** (`buildrun:<projectID>`, later `terminal:<paneID>`) instead of a
///     `SessionID` — the feed attributes app-scoped events without inventing a fake session.
///
/// `shared` is process-wide; ``AppStore`` installs the sink at startup (it owns the observation
/// seam), and spawn sites only ever call ``register(origin:)``/``unregister(token:)``.
public actor HostShellEventListener {
    public static let shared = HostShellEventListener()

    /// Delivery of one decoded shell **batch**, tagged with the registering spawn's origin id.
    /// Batch-shaped like the session route (docs/NASH_STREAM_PERF_PLAN.md §P1) — the per-delivery
    /// cost is the actor hop and the viewer's ring append, so a 200-event post pays it once.
    public typealias Sink = @Sendable (String, [MCPApprovalServer.ShellReportCall]) async -> Void

    private let server: MCPApprovalServer
    private var boundPort: UInt16?
    private var sink: Sink?
    /// token → origin for the spawns currently in flight.
    private var origins: [String: String] = [:]

    /// `internal`-visible init for tests; production uses ``shared``.
    init(server: MCPApprovalServer = MCPApprovalServer()) {
        self.server = server
    }

    /// Install the one consumer (AppStore's `observeAppScopedShellEvent`). Until a sink exists,
    /// ``register(origin:)`` refuses — events with nobody to observe them shouldn't bind a port.
    public func install(sink: @escaping Sink) {
        self.sink = sink
    }

    /// Register one spawn under `origin`, lazily binding the loopback listener on first use.
    /// Returns the `NUCLEIC_SHELL_HOOK_URL` value and the spawn's bearer token, or `nil` when
    /// observation is unavailable (no sink installed, or the bind failed) — the spawn then simply
    /// runs unobserved; never block or fail the user's build over observability (§5.5).
    public func register(origin: String) async -> (hookURL: String, token: String)? {
        guard sink != nil else { return nil }
        if boundPort == nil {
            boundPort = try? await server.start(host: "127.0.0.1")
        }
        guard let port = boundPort, port != 0 else { return nil }
        let token = UUID().uuidString
        origins[token] = origin
        await server.registerShellReport(token: token) { [weak self] calls in
            Task { await self?.deliver(token: token, calls: calls) }
        }
        return ("http://127.0.0.1:\(port)\(MCPApprovalServer.shellEventPath)", token)
    }

    /// The spawn is over: drop its token so any further post 401s.
    public func unregister(token: String) async {
        origins.removeValue(forKey: token)
        await server.unregister(token: token)
    }

    private func deliver(token: String, calls: [MCPApprovalServer.ShellReportCall]) async {
        guard let origin = origins[token], let sink else { return }
        await sink(origin, calls)
    }
}
