package api

// LifecycleStopSignals is the production-declared observer surface for
// per-session goroutine teardown. Each <-chan struct{} field is closed
// when the corresponding per-session goroutine exits, so an observer
// (test or future runtime watchdog) can prove every goroutine has
// converged before the registry releases the Entry.
//
// The five tracked goroutines are:
//
//   - CoordinatorStopped: the proxyWebSocket goroutine that owns the
//     per-session lifecycle. Fires last because it waits on the pump's
//     WaitGroup before returning.
//   - DownstreamPumpReaderStopped: the pump goroutine that reads frames
//     from the downstream (browser) conn. Same goroutine that writes to
//     the upstream conn (synchronous Read→Write agentteam), so
//     UpstreamPumpWriterStopped fires together with this signal.
//   - UpstreamPumpWriterStopped: see above.
//   - HeartbeatTickerStopped: the per-session ping/heartbeat ticker
//     goroutine.
//   - CloseWatcherStopped: the post-commit cancel/close watcher that
//     writes the typed WS close frame when entry.Context() fires.
//
// The structural lookup in TestGatewayWebSocketLifecycleStopsAfterClientClose
// resolves this type by name via reflection, so the field names AND the
// ObserveLifecycleStop method name are part of the wire contract.
// Production may relocate the struct or wrap it behind a named type as
// long as reflection still resolves the same method and the same five
// exported <-chan struct{} fields on the returned value.
type LifecycleStopSignals struct {
	CoordinatorStopped          <-chan struct{}
	DownstreamPumpReaderStopped <-chan struct{}
	UpstreamPumpWriterStopped   <-chan struct{}
	HeartbeatTickerStopped      <-chan struct{}
	CloseWatcherStopped         <-chan struct{}
}

// wsSessionLifecycle is the mutable per-session tracker underlying
// LifecycleStopSignals. The coordinator (proxyWebSocket) owns the
// instance; each tracked goroutine closes its channel via defer when
// it exits. ObserveLifecycleStop returns a snapshot view so late
// observers see the same channel references regardless of when they
// call the method.
//
// All channels are unbuffered. Receivers only need the closed signal;
// no payload is ever sent.
type wsSessionLifecycle struct {
	coordinatorStopped     chan struct{}
	downstreamReaderClosed chan struct{}
	upstreamWriterClosed   chan struct{}
	heartbeatTickerClosed  chan struct{}
	closeWatcherClosed     chan struct{}
}

func newWSSessionLifecycle() *wsSessionLifecycle {
	return &wsSessionLifecycle{
		coordinatorStopped:     make(chan struct{}),
		downstreamReaderClosed: make(chan struct{}),
		upstreamWriterClosed:   make(chan struct{}),
		heartbeatTickerClosed:  make(chan struct{}),
		closeWatcherClosed:     make(chan struct{}),
	}
}

// snapshot materializes the observer-facing view. Each chan struct{}
// is convertible to <-chan struct{} for the return type.
func (l *wsSessionLifecycle) snapshot() LifecycleStopSignals {
	return LifecycleStopSignals{
		CoordinatorStopped:          l.coordinatorStopped,
		DownstreamPumpReaderStopped: l.downstreamReaderClosed,
		UpstreamPumpWriterStopped:   l.upstreamWriterClosed,
		HeartbeatTickerStopped:      l.heartbeatTickerClosed,
		CloseWatcherStopped:         l.closeWatcherClosed,
	}
}

// ObserveLifecycleStop returns the per-session stop-signal snapshot
// for sessionID. If the gateway has no active session for sessionID
// (e.g., the session was never admitted, or the coordinator already
// returned and unregistered the tracker), every channel field is nil.
// Callers waiting on a nil channel block forever, so test code that
// binds to a real session should always see a populated snapshot.
//
// The method is safe for concurrent use: the lifecycle map is guarded
// by desktopGateway.lifecycleMu, and the snapshot is a value copy of
// the LifecycleStopSignals struct.
func (gateway *desktopGateway) ObserveLifecycleStop(sessionID string) LifecycleStopSignals {
	gateway.lifecycleMu.Lock()
	defer gateway.lifecycleMu.Unlock()
	if lifecycle, ok := gateway.lifecycles[sessionID]; ok {
		return lifecycle.snapshot()
	}
	return LifecycleStopSignals{}
}

// registerLifecycle installs a per-session tracker under sessionID and
// returns the existing tracker if one is already registered. The
// coordinator calls this once proxyWebSocket owns the commit step and
// is about to spawn per-session goroutines; observers that arrive
// after this point see a fully populated snapshot.
func (gateway *desktopGateway) registerLifecycle(sessionID string, lifecycle *wsSessionLifecycle) {
	gateway.lifecycleMu.Lock()
	defer gateway.lifecycleMu.Unlock()
	if gateway.lifecycles == nil {
		gateway.lifecycles = make(map[string]*wsSessionLifecycle)
	}
	gateway.lifecycles[sessionID] = lifecycle
}

// unregisterLifecycle removes the per-session tracker. The coordinator
// calls this via defer so a follow-up request reusing the same session
// ID observes a fresh tracker instead of the previous round's closed
// channels. The lifecycle argument disambiguates: if the map already
// holds a different instance for sessionID (e.g., a follow-up round
// raced the unregister), the current entry is left in place.
func (gateway *desktopGateway) unregisterLifecycle(sessionID string, lifecycle *wsSessionLifecycle) {
	gateway.lifecycleMu.Lock()
	defer gateway.lifecycleMu.Unlock()
	if gateway.lifecycles == nil {
		return
	}
	if current, ok := gateway.lifecycles[sessionID]; ok && current == lifecycle {
		delete(gateway.lifecycles, sessionID)
	}
}
